Compare commits

..

38 Commits

Author SHA1 Message Date
Zeno Kapitein 44fb83655b Changes to constraints 2025-06-23 17:07:37 +02:00
Zeno Kapitein d0b93fcae0 Cleanup 2025-06-23 16:44:39 +02:00
Zeno Kapitein 2e5d9418d4 New design 2025-06-23 16:41:39 +02:00
Zeno Kapitein 1000978049 Merge branch 'main' into redesign-tabs 2025-06-18 14:46:20 +02:00
Zeno Kapitein b475655e7a Merge branch 'redesign-tabs' of https://github.com/GitbookIO/gitbook into redesign-tabs 2025-06-18 14:45:12 +02:00
Steven H 73e0cbb2d6 Fix an issue where PDF export URLs were not bringing their query params. (#3351) 2025-06-18 13:26:20 +01:00
Steven H 392f59450c Improve performance of InlineLinkTooltip (#3339) 2025-06-18 12:15:45 +01:00
conico974 b4918f60ce fix variant space change when pathname equals space-path (#3343)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-17 22:31:32 +02:00
Samy Pessé 8f7c304d58 Fix crash when rendering ogimage with invalid icon (#3348) 2025-06-17 20:25:21 +02:00
Samy Pessé a3a944d7dd Fix crash during rendering of ogimage for VA sites with default icon (#3347) 2025-06-17 19:38:47 +02:00
Samy Pessé 88a35ed057 Fix crash when integration is triggering invalid requests (#3346) 2025-06-17 19:23:52 +02:00
Samy Pessé 67998b6f15 Fix ogimage generation failing with some JPEG images (#3345) 2025-06-17 19:10:15 +02:00
Samy Pessé ff6d1150a5 Use getRevisionPageDocument on v2 (#3342) 2025-06-17 16:12:58 +02:00
Samy Pessé 500c8cb649 Don't crash ogimage generation on RTL text (#3341) 2025-06-17 13:04:50 +02:00
Samy Pessé 6859f7d239 Fix rendering of ogimage when logo or icon are AVIF images (#3336)
Co-authored-by: Steven H <steven@gitbook.io>
2025-06-17 00:31:07 +02:00
Samy Pessé dfa8a37be1 Don't cache unexpected API errors for more than a few minutes (#3334) 2025-06-17 00:16:52 +02:00
Samy Pessé 11a6511b7a Fix crash when integration script fails to render block (#3332) 2025-06-16 20:06:33 +02:00
Samy Pessé af98402655 Add support for inline icons (#3329) 2025-06-16 19:15:22 +02:00
Samy Pessé b7a0db3339 Fix rendering of ogimage with SVG logos (#3328) 2025-06-16 16:59:54 +02:00
Greg Bergé 382a19885b Hide empty section groups (#3327) 2025-06-16 13:52:42 +00:00
Utku Ufuk d99da6a3ae Ignore case while pattern-matching to highlight search results (#3326) 2025-06-16 12:33:00 +00:00
Samy Pessé 72cd0e59e6 Replace withoutConcurrency by a smarter React.cache (#3325) 2025-06-16 13:37:45 +02:00
Utku Ufuk 42d88da73c Fix UX issue about highlighting the search term in search result sections (#3323) 2025-06-16 13:31:15 +03:00
Taran Vohra 2863fe0dc1 Use resolvePublishedContentByUrl instead of the deprecated resolution endpoint (#3310) 2025-06-16 09:59:55 +05:30
conico974 87218062be Use Promise.all whenever possible (#3319)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-14 11:10:26 +02:00
Samy Pessé 8a3910e208 Experiment with hiding sections without a matching site space (#3318) 2025-06-13 15:55:04 +02:00
conico974 6294bbb53c Fix error propagating (#3317)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-13 15:46:37 +02:00
Zeno Kapitein a28a997851 Add margin to adjacent buttons (#3316) 2025-06-13 15:42:00 +02:00
Zeno Kapitein e2dd8a9288 Changeset 2025-06-13 13:30:56 +02:00
Zeno Kapitein 17df1be3ef Second iteration, ready 2025-06-13 13:30:56 +02:00
Zeno Kapitein 34a11d37a7 First iteration 2025-06-13 13:30:56 +02:00
spastorelli 28a9ee701e Add paypal.gitbook.com to ADAPTIVE_CONTENT_HOSTS list (#3315) 2025-06-13 10:10:10 +00:00
Samy Pessé 50e8b2ec52 Don't use next/server after (#3314) 2025-06-13 11:30:18 +02:00
Samy Pessé 315717fa2d Disable the background fetch of getRevisionPageDocument (#3313) 2025-06-13 11:17:33 +02:00
Samy Pessé da67711c90 Fix crash on Vercel (#3311) 2025-06-12 23:37:38 +02:00
Zeno Kapitein 60015f5807 Changeset 2025-06-12 19:39:12 +02:00
Zeno Kapitein 8e3e5147cf Second iteration, ready 2025-06-12 19:38:31 +02:00
Zeno Kapitein 8e75e3bec5 First iteration 2025-06-11 16:50:09 +02:00
68 changed files with 2088 additions and 1347 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix UX issue about highlighting the search term in search result sections
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add support for inline icons.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix crash when integration script fails to render block.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Optimize performances by using a smarter per-request cache arround data cached functions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Fix rendering of ogimage with SVG logos.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix crash when integration is triggering invalid requests.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-contentkit": patch
---
Add basic error handling when transitioning between states.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Don't crash ogimage generation on RTL text, as a workaround until we can support it.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix rendering of ogimage when logo or icon are AVIF images.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add margin to adjacent buttons
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ogimage generation failing with some JPEG images.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Don't cache unexpected API errors for more than a few minutes.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Fix an issue where PDF export URLs were not keeping their query params.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
add a global error boundary
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Ignore case while highlighting search results.
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"gitbook-v2": patch
---
Fix InlineLinkTooltip having a negative impact on performance, especially on larger pages.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix crash during rendering of ogimage for VA sites with default icon.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
New design for tab blocks
+8 -2
View File
@@ -56,13 +56,19 @@ git clone https://github.com/gitbookIO/gitbook.git
bun install
```
4. Start your local development server.
4. Run build.
```
bun build:v2
```
5. Start your local development server.
```
bun dev:v2
```
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples:
+11 -2
View File
@@ -85,8 +85,10 @@
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"framer-motion": "^10.16.14",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
@@ -169,6 +171,7 @@
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "^15.3.2",
"object-identity": "^0.1.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
@@ -283,7 +286,7 @@
"react-dom": "^19.0.0",
},
"catalog": {
"@gitbook/api": "^0.120.0",
"@gitbook/api": "^0.121.0",
},
"packages": {
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
@@ -648,7 +651,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
"@gitbook/api": ["@gitbook/api@0.120.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-FiRmPiSBwobMxmNjd14QkkOdM95BAPLDDRShgpS9Vsd8lHjNMyZfrJKVJTsJUuFcgYoi4cqNw9yu/TiUBUgv3g=="],
"@gitbook/api": ["@gitbook/api@0.121.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-o4/N24RM0Rg8S/3yPDjPmt6TbQF+1iZmg9q9QKxOxMqpQ2bZmMUqS7dSkeqEbEBMALx/m/x0xQlJbEJGbOwteg=="],
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
@@ -1706,6 +1709,8 @@
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -2066,6 +2071,8 @@
"ignore": ["ignore@7.0.3", "", {}, "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA=="],
"image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-lazy": ["import-lazy@2.1.0", "", {}, "sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A=="],
@@ -2438,6 +2445,8 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-identity": ["object-identity@0.1.2", "", {}, "sha512-Px5puVllX5L2aBjbcfXpiG5xXeq6OE8RckryTeP2Zq+0PgYrCGJXmC6LblWgknKSJs11Je2W4U2NOWFj3t/QXQ=="],
"object-inspect": ["object-inspect@1.13.2", "", {}, "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g=="],
"object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="],
+1 -1
View File
@@ -36,7 +36,7 @@
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.120.0"
"@gitbook/api": "^0.121.0"
}
},
"patchedDependencies": {
+2 -1
View File
@@ -14,7 +14,8 @@
"react-dom": "^19.0.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"warn-once": "^0.1.1"
"warn-once": "^0.1.1",
"object-identity": "^0.1.2"
},
"devDependencies": {
"gitbook": "*",
@@ -0,0 +1,18 @@
'use client';
import NextError from 'next/error';
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
console.error('Global error:', error);
return (
<html lang="en">
<body>
<NextError statusCode={undefined as any} />
</body>
</html>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'bun:test';
import { withStableRef } from './cache';
describe('withStableRef', () => {
it('should return primitive values as is', () => {
const toStableRef = withStableRef();
expect(toStableRef(42)).toBe(42);
expect(toStableRef('hello')).toBe('hello');
expect(toStableRef(true)).toBe(true);
expect(toStableRef(null)).toBe(null);
expect(toStableRef(undefined)).toBe(undefined);
});
it('should return the same reference for identical objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 2 };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(obj1);
expect(ref1).not.toBe(obj2);
});
it('should return the same reference for identical arrays', () => {
const toStableRef = withStableRef();
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const ref1 = toStableRef(arr1);
const ref2 = toStableRef(arr2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(arr1);
expect(ref1).not.toBe(arr2);
});
it('should return the same reference for identical nested objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: { b: 1 }, c: [2, 3] };
const obj2 = { a: { b: 1 }, c: [2, 3] };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(obj1);
expect(ref1).not.toBe(obj2);
});
it('should return different references for different objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: 1 };
const obj2 = { a: 2 };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).not.toBe(ref2);
});
it('should maintain reference stability across multiple calls', () => {
const toStableRef = withStableRef();
const obj = { a: 1 };
const ref1 = toStableRef(obj);
const ref2 = toStableRef(obj);
const ref3 = toStableRef(obj);
expect(ref1).toBe(ref2);
expect(ref2).toBe(ref3);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { identify } from 'object-identity';
import * as React from 'react';
/**
* Equivalent to `React.cache` but with support for non-primitive arguments.
* As `React.cache` only uses `Object.is` to compare arguments, it will not work with non-primitive arguments.
*/
export function cache<Args extends any[], Return>(fn: (...args: Args) => Return) {
const cached = React.cache(fn);
return (...args: Args) => {
const toStableRef = getWithStableRef();
const stableArgs = args.map((value) => {
return toStableRef(value);
}) as Args;
return cached(...stableArgs);
};
}
/**
* To ensure memory is garbage collected between each request, we use a per-request cache to store the ref maps.
*/
const getWithStableRef = React.cache(withStableRef);
/**
* Create a function that converts a value to a stable reference.
*/
export function withStableRef(): <T>(value: T) => T {
const reverseIndex = new WeakMap<object, string>();
const refIndex = new Map<string, object>();
return <T>(value: T) => {
if (isPrimitive(value)) {
return value;
}
const objectValue = value as object;
const index = reverseIndex.get(objectValue);
if (index !== undefined) {
return refIndex.get(index) as T;
}
const hash = identify(objectValue);
reverseIndex.set(objectValue, hash);
const existing = refIndex.get(hash);
if (existing !== undefined) {
return existing as T;
}
// first time we've seen this shape
refIndex.set(hash, objectValue);
return value;
};
}
function isPrimitive(value: any): boolean {
return value === null || typeof value !== 'object';
}
File diff suppressed because it is too large Load Diff
+46 -5
View File
@@ -1,4 +1,5 @@
import { GitBookAPIError } from '@gitbook/api';
import { unstable_cacheLife as cacheLife } from 'next/cache';
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
export class DataFetcherError extends Error {
@@ -47,11 +48,7 @@ export function getDataOrNull<T>(
return response.then((result) => getDataOrNull(result, ignoreErrors));
}
if (response.error) {
if (ignoreErrors.includes(response.error.code)) return null;
throw new DataFetcherError(response.error.message, response.error.code);
}
return response.data;
return ignoreDataFetcherErrors(response, ignoreErrors).data ?? null;
}
/**
@@ -93,6 +90,50 @@ export async function wrapDataFetcherError<T>(
}
}
/**
* Wrap an async execution to handle errors and return a DataFetcherResponse.
* This should be used inside 'use cache' functions.
*/
export async function wrapCacheDataFetcherError<T>(
fn: () => Promise<T>
): Promise<DataFetcherResponse<T>> {
const result = await wrapDataFetcherError(fn);
if (result.error && result.error.code >= 500) {
// We don't want to cache errors for too long.
// as the API might
cacheLife('minutes');
}
return result;
}
/**
* Ignore some data fetcher errors.
*/
export function ignoreDataFetcherErrors<T>(
response: DataFetcherResponse<T>,
ignoreErrors?: number[]
): DataFetcherResponse<T>;
export function ignoreDataFetcherErrors<T>(
response: Promise<DataFetcherResponse<T>>,
ignoreErrors?: number[]
): Promise<DataFetcherResponse<T>>;
export function ignoreDataFetcherErrors<T>(
response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>,
ignoreErrors: number[] = [404]
): DataFetcherResponse<T> | Promise<DataFetcherResponse<T>> {
if (response instanceof Promise) {
return response.then((result) => ignoreDataFetcherErrors(result, ignoreErrors));
}
if (response.error) {
if (ignoreErrors.includes(response.error.code)) {
return response;
}
throw new DataFetcherError(response.error.message, response.error.code);
}
return response;
}
/**
* Get a data fetcher exposable error from a JS error.
*/
+22 -75
View File
@@ -1,7 +1,7 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import type { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import { apiClient } from './api';
import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types';
@@ -18,85 +18,32 @@ interface LookupPublishedContentByUrlInput {
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'resolvePublishedContentByUrl',
name: url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
);
},
});
}
/**
* Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*
* @deprecated use resolvePublishedContentByUrl.
*
*/
export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'getPublishedContentByURL',
name: url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url,
visitorAuthToken: input.visitorPayload.jwtToken ?? undefined,
redirectOnError: input.redirectOnError,
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{ signal }
)
)
);
},
});
}
type TryCatch<T> = ReturnType<typeof tryCatch<T>>;
async function lookupPublishedContentByUrl(input: {
url: string;
fetchLookupAPIResult: (args: {
url: string;
signal: AbortSignal;
}) => TryCatch<Awaited<ReturnType<GitBookAPI['urls']['resolvePublishedContentByUrl']>>>;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
export async function lookupPublishedContentByUrl(
input: LookupPublishedContentByUrlInput
): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const callResult = await input.fetchLookupAPIResult({
url: alternative.url,
signal,
});
const api = apiClient({ apiToken: input.apiToken });
const callResult = await trace(
{
operation: 'resolvePublishedContentByUrl',
name: alternative.url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url: alternative.url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
);
if (callResult.error) {
if (alternative.primary) {
@@ -1,89 +0,0 @@
import type { GitBookDataFetcher } from './types';
/**
* Wrap a function by preventing concurrent executions of the same function.
* With a logic to work per-request in Cloudflare Workers.
*/
export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
dataFetcherObject: GitBookDataFetcher,
wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType>
): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> {
const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>();
return (key: string, ...args: ArgsType) => {
const globalContext = dataFetcherObject;
/**
* Cache storage that is scoped to the current request when executed in Cloudflare Workers,
* to avoid "Cannot perform I/O on behalf of a different request" errors.
*/
const promiseCache =
globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>();
globalPromiseCache.set(globalContext, promiseCache);
const concurrent = promiseCache.get(key);
if (concurrent) {
return concurrent;
}
const promise = (async () => {
try {
const result = await wrapped(key, ...args);
return result;
} finally {
promiseCache.delete(key);
}
})();
promiseCache.set(key, promise);
return promise;
};
}
/**
* Wrap a function by passing it a cache key that is computed from the function arguments.
*/
export function withCacheKey<ArgsType extends any[], ReturnType>(
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
): (...args: ArgsType) => Promise<ReturnType> {
return (...args: ArgsType) => {
const cacheKey = getCacheKey(args);
return wrapped(cacheKey, ...args);
};
}
/**
* Compute a cache key from the function arguments.
*/
function getCacheKey(args: any[]) {
return JSON.stringify(deepSortValue(args));
}
function deepSortValue(value: unknown): unknown {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
) {
return value;
}
if (Array.isArray(value)) {
return value.map(deepSortValue);
}
if (value && typeof value === 'object') {
return Object.entries(value)
.map(([key, subValue]) => {
return [key, deepSortValue(subValue)] as const;
})
.sort((a, b) => {
return a[0].localeCompare(b[0]);
});
}
return value;
}
+2 -30
View File
@@ -1,4 +1,4 @@
import { waitUntil } from '@/lib/waitUntil';
import { isV2 } from '@/lib/v2';
import type { JSONDocument, RevisionPageDocument } from '@gitbook/api';
import type { GitBookSiteContext, GitBookSpaceContext } from '../context';
import { getDataOrNull } from './errors';
@@ -12,10 +12,7 @@ export async function getPageDocument(
): Promise<JSONDocument | null> {
const { dataFetcher, space } = context;
if (
'site' in context &&
(context.site.id === 'site_JOVzv' || context.site.id === 'site_IxAYj')
) {
if (isV2()) {
return getDataOrNull(
dataFetcher.getRevisionPageDocument({
spaceId: space.id,
@@ -41,30 +38,5 @@ export async function getPageDocument(
);
}
// Pre-fetch the document to start filling the cache before we migrate to this API.
if (isInPercentRollout(space.id, 10)) {
await waitUntil(
getDataOrNull(
dataFetcher.getRevisionPageDocument({
spaceId: space.id,
revisionId: space.revision,
pageId: page.id,
})
)
);
}
return null;
}
function isInPercentRollout(value: string, rollout: number) {
return getRandomPercent(value) < rollout;
}
function getRandomPercent(value: string) {
const hash = value.split('').reduce((acc, char) => {
return acc + char.charCodeAt(0);
}, 0);
return hash % 100;
}
@@ -13,7 +13,7 @@ export interface CloudflareImageJsonFormat {
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
format?: 'webp' | 'avif' | 'json' | 'jpeg' | 'png';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
+4 -17
View File
@@ -16,10 +16,9 @@ import {
import { serveResizedImage } from '@/routes/image';
import {
DataFetcherError,
getPublishedContentByURL,
getVisitorAuthBasePath,
lookupPublishedContentByUrl,
normalizeURL,
resolvePublishedContentByUrl,
throwIfDataError,
} from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
@@ -34,17 +33,6 @@ export const config = {
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
/**
* Temporary list of hosts to test adaptive content using the new resolution API.
*/
const ADAPTIVE_CONTENT_HOSTS = [
'docs.gitbook.com',
'adaptive-docs.gitbook-staging.com',
'enriched-content-playground.gitbook-staging.io',
'docs.testgitbook.com',
'launchdarkly-site.gitbook.education',
];
export async function middleware(request: NextRequest) {
try {
const requestURL = new URL(request.url);
@@ -103,11 +91,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
});
const withAPIToken = async (apiToken: string | null) => {
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
? resolvePublishedContentByUrl
: getPublishedContentByURL;
const siteURLData = await throwIfDataError(
resolve({
lookupPublishedContentByUrl({
url: siteRequestURL.toString(),
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
@@ -274,6 +259,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
console.log(`rewriting ${request.nextUrl.toString()} to ${route}`);
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
rewrittenURL.search = request.nextUrl.search; // Preserve the original search params
const response = NextResponse.rewrite(rewrittenURL, {
request: {
headers: requestHeaders,
+5
View File
@@ -603,6 +603,11 @@ const testCases: TestsCase[] = [
url: 'blocks/emojis',
run: waitForCookiesDialog,
},
{
name: 'Icons',
url: 'blocks/icons',
run: waitForCookiesDialog,
},
{
name: 'Links',
url: 'blocks/links',
+170
View File
@@ -0,0 +1,170 @@
import { argosScreenshot } from '@argos-ci/playwright';
import { expect, test } from '@playwright/test';
import { getContentTestURL } from '../tests/utils';
import { waitForIcons } from './util';
test.describe('PDF export', () => {
test('export all pages as PDF (e2e)', async ({ page }) => {
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(
getContentTestURL(
'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/~gitbook/pdf?limit=10'
)
);
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: undefined,
fullPage: true,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
},
});
});
test('export all pages as PDF (GitBook docs)', async ({ page }) => {
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(getContentTestURL('https://gitbook.com/docs/~gitbook/pdf?limit=10'));
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: undefined,
fullPage: true,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
},
});
});
test('export a single page as PDF (e2e)', async ({ page }) => {
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(
getContentTestURL(
'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/~gitbook/pdf?page=Bw7LjWwgTjV8nIV4s7rs&only=yes&limit=2'
)
);
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: undefined,
fullPage: true,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
},
});
});
test('export a single page as PDF (GitBook docs)', async ({ page }) => {
// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
},
});
} else {
await route.continue();
}
});
await page.goto(
getContentTestURL(
'https://gitbook.com/docs/~gitbook/pdf?page=DfnNkU49mvLe2ythHAyx&only=yes&limit=2'
)
);
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: undefined,
fullPage: true,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
},
});
});
});
+1 -1
View File
@@ -346,7 +346,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
/**
* Wait for all icons present on the page to be loaded.
*/
async function waitForIcons(page: Page) {
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urlStates: Record<
string,
+4 -2
View File
@@ -8,7 +8,7 @@
"build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
"start": "next start",
"typecheck": "tsc --noEmit",
"e2e": "playwright test e2e/internal.spec.ts",
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts",
"e2e-customers": "playwright test e2e/customers.spec.ts",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
@@ -81,7 +81,9 @@
"usehooks-ts": "^3.1.0",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5",
"zustand": "^5.0.3"
"zustand": "^5.0.3",
"image-size": "^2.0.2",
"direction": "^2.0.1"
},
"devDependencies": {
"@argos-ci/playwright": "^5.0.3",
@@ -19,14 +19,14 @@ export async function AdClassicRendering({
insightsAd: SiteInsightsAd | null;
context: GitBookBaseContext;
}) {
const smallImgSrc =
const [smallImgSrc, logoSrc] = await Promise.all([
'smallImage' in ad
? await getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 })
: null;
const logoSrc =
? getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 })
: null,
'logo' in ad
? await getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 })
: null;
? getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 })
: null,
]);
return (
<Link
rel="sponsored noopener"
@@ -43,10 +43,12 @@ interface FetchPlaceholderAdOptions {
* and properly access user-agent and IP.
*/
export async function renderAd(options: FetchAdOptions) {
const context = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const [context, result] = await Promise.all([
isV2() ? getServerActionBaseContext() : getV1BaseContext(),
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
]);
const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
@@ -43,7 +43,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
aria-describedby={describedById}
className={tcls(
'fixed',
'z-10',
'z-50',
'bg-tint-base',
'rounded',
'straight-corners:rounded-none',
@@ -12,6 +12,7 @@ import {
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import { type BundledLanguage, bundledLanguages } from 'shiki/langs';
import { nullIfNever } from '@/lib/typescript';
import { plainHighlight } from './plain-highlight';
export type HighlightLine = {
@@ -263,16 +264,28 @@ function getPlainCodeBlockLine(
if (node.object === 'text') {
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
} else {
const start = index + content.length;
content += getPlainCodeBlockLine(node, index + content.length, inlines);
const end = index + content.length;
switch (node.type) {
case 'annotation': {
const start = index + content.length;
content += getPlainCodeBlockLine(node, index + content.length, inlines);
const end = index + content.length;
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
}
break;
}
case 'expression': {
break;
}
default: {
nullIfNever(node);
break;
}
}
}
}
@@ -28,6 +28,14 @@ export interface DocumentContext {
* @default true
*/
wrapBlocksInSuspense?: boolean;
/**
* True if link previews should be rendered.
* This is used to limit the number of link previews rendered in a document.
* If false, no link previews will be rendered.
* @default false
*/
shouldRenderLinkPreviews?: boolean;
}
export interface DocumentContextProps {
@@ -5,6 +5,7 @@ import { Annotation } from './Annotation/Annotation';
import type { DocumentContextProps } from './DocumentView';
import { Emoji } from './Emoji';
import { InlineButton } from './InlineButton';
import { InlineIcon } from './InlineIcon';
import { InlineImage } from './InlineImage';
import { InlineLink } from './InlineLink';
import { InlineMath } from './Math';
@@ -47,6 +48,8 @@ export function Inline<T extends DocumentInline>(props: InlineProps<T>) {
return <InlineImage {...contextProps} inline={inline} />;
case 'button':
return <InlineButton {...contextProps} inline={inline} />;
case 'icon':
return <InlineIcon {...contextProps} inline={inline} />;
case 'expression':
// The GitBook API should take care of evaluating expressions.
// We should never need to render them.
@@ -17,18 +17,22 @@ export async function InlineButton(props: InlineProps<api.DocumentInlineButton>)
}
return (
<Button
href={resolved.href}
label={inline.data.label}
// TODO: use a variant specifically for user-defined buttons.
variant={inline.data.kind}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
}}
/>
// Set the leading to have some vertical space between adjacent buttons
<span className="inline-button leading-[3rem] [&:has(+.inline-button)]:mr-2">
<Button
href={resolved.href}
label={inline.data.label}
// TODO: use a variant specifically for user-defined buttons.
variant={inline.data.kind}
className="leading-normal"
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
}}
/>
</span>
);
}
@@ -0,0 +1,10 @@
import type { DocumentInlineIcon } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import type { InlineProps } from './Inline';
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
const { inline } = props;
return <Icon icon={inline.data.icon as IconName} className="inline size-[1em]" />;
}
@@ -1,61 +0,0 @@
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { Icon } from '@gitbook/icons';
import { StyledLink } from '../primitives';
import type { InlineProps } from './Inline';
import { InlineLinkTooltip } from './InlineLinkTooltip';
import { Inlines } from './Inlines';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
// We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link.
resolveAnchorText: false,
})
: null;
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
return (
<InlineLinkTooltip inline={inline} context={context.contentContext} resolved={resolved}>
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
/>
) : null}
</StyledLink>
</InlineLinkTooltip>
);
}
@@ -0,0 +1,153 @@
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { getSpaceLanguage, tString } from '@/intl/server';
import { languages } from '@/intl/translations';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { Icon } from '@gitbook/icons';
import type { GitBookAnyContext } from '@v2/lib/context';
import { StyledLink } from '../../primitives';
import type { InlineProps } from '../Inline';
import { Inlines } from '../Inlines';
import { InlineLinkTooltip } from './InlineLinkTooltip';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
// We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link.
resolveAnchorText: false,
})
: null;
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
const content = (
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
/>
) : null}
</StyledLink>
);
if (context.shouldRenderLinkPreviews) {
return (
<InlineLinkTooltipWrapper
inline={inline}
context={context.contentContext}
resolved={resolved}
>
{content}
</InlineLinkTooltipWrapper>
);
}
return content;
}
/**
* An SSR component that renders a link with a tooltip.
* Essentially it pulls the minimum amount of props from the context to render the tooltip.
*/
function InlineLinkTooltipWrapper(props: {
inline: DocumentInlineLink;
context: GitBookAnyContext;
children: React.ReactNode;
resolved: ResolvedContentRef;
}) {
const { inline, context, resolved, children } = props;
let breadcrumbs = resolved.ancestors ?? [];
const language =
'customization' in context ? getSpaceLanguage(context.customization) : languages.en;
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isExternal) {
breadcrumbs = [
{
label: tString(language, 'link_tooltip_external_link'),
},
];
}
if (isSamePage) {
breadcrumbs = [
{
label: tString(language, 'link_tooltip_page_anchor'),
icon: <Icon icon="arrow-down-short-wide" className="size-3" />,
},
];
resolved.subText = undefined;
}
const aiSummary: { pageId: string; spaceId: string } | undefined = (() => {
if (isExternal) {
return;
}
if (isSamePage) {
return;
}
if (!('customization' in context) || !context.customization.ai?.pageLinkSummaries.enabled) {
return;
}
if (!('page' in context) || !('page' in inline.data.ref)) {
return;
}
if (inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor') {
return {
pageId: resolved.page?.id ?? inline.data.ref.page ?? context.page.id,
spaceId: inline.data.ref.space ?? context.space.id,
};
}
})();
return (
<InlineLinkTooltip
breadcrumbs={breadcrumbs}
isExternal={isExternal}
isSamePage={isSamePage}
aiSummary={aiSummary}
openInNewTabLabel={tString(language, 'open_in_new_tab')}
target={{
href: resolved.href,
text: resolved.text,
subText: resolved.subText,
icon: resolved.icon,
}}
>
{children}
</InlineLinkTooltip>
);
}
@@ -0,0 +1,73 @@
'use client';
import dynamic from 'next/dynamic';
import React from 'react';
const LoadingValueContext = React.createContext<React.ReactNode>(null);
// To avoid polluting the RSC payload with the tooltip implementation,
// we lazily load it on the client side. This way, the tooltip is only loaded
// when the user interacts with the link, and it doesn't block the initial render.
const InlineLinkTooltipImpl = dynamic(
() => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl),
{
// Disable server-side rendering for this component, it's only
// visible on user interaction.
ssr: false,
loading: () => {
// The fallback should be the children (the content of the link),
// but as next/dynamic is aiming for feature parity with React.lazy,
// it doesn't support passing children to the loading component.
// https://github.com/vercel/next.js/issues/7906
const children = React.useContext(LoadingValueContext);
return <>{children}</>;
},
}
);
/**
* Tooltip for inline links. It's lazily loaded to avoid blocking the initial render
* and polluting the RSC payload.
*
* The link text and href have already been rendered on the server for good SEO,
* so we can be as lazy as possible with the tooltip.
*/
export function InlineLinkTooltip(props: {
isSamePage: boolean;
isExternal: boolean;
aiSummary?: { pageId: string; spaceId: string };
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
children: React.ReactNode;
}) {
const { children, ...rest } = props;
const [shouldLoad, setShouldLoad] = React.useState(false);
// Once the browser is idle, we set shouldLoad to true.
// NOTE: to be slightly more performant, we could load when a link is hovered.
// But I found this was too much of a delay for the tooltip to appear.
// Loading on idle is a good compromise, as it allows the initial render to be fast,
// while still loading the tooltip in the background and not polluting the RSC payload.
React.useEffect(() => {
if ('requestIdleCallback' in window) {
(window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true));
} else {
// fallback for old browsers
setTimeout(() => setShouldLoad(true), 2000);
}
}, []);
return shouldLoad ? (
<LoadingValueContext.Provider value={children}>
<InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl>
</LoadingValueContext.Provider>
) : (
children
);
}
@@ -1,55 +1,27 @@
import type { DocumentInlineLink } from '@gitbook/api';
import type { ResolvedContentRef } from '@/lib/references';
import { getSpaceLanguage } from '@/intl/server';
import { tString } from '@/intl/translate';
import { languages } from '@/intl/translations';
import { getNodeText } from '@/lib/document';
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as Tooltip from '@radix-ui/react-tooltip';
import type { GitBookAnyContext } from '@v2/lib/context';
import { Fragment } from 'react';
import { AIPageLinkSummary } from '../Adaptive/AIPageLinkSummary';
import { Button, StyledLink } from '../primitives';
import { AIPageLinkSummary } from '../../Adaptive';
import { Button, StyledLink } from '../../primitives';
export async function InlineLinkTooltip(props: {
inline: DocumentInlineLink;
context: GitBookAnyContext;
export function InlineLinkTooltipImpl(props: {
isSamePage: boolean;
isExternal: boolean;
aiSummary?: { pageId: string; spaceId: string };
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
children: React.ReactNode;
resolved: ResolvedContentRef;
}) {
const { inline, context, resolved, children } = props;
let breadcrumbs = resolved.ancestors;
const language =
'customization' in context ? getSpaceLanguage(context.customization) : languages.en;
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isExternal) {
breadcrumbs = [
{
label: tString(language, 'link_tooltip_external_link'),
},
];
}
if (isSamePage) {
breadcrumbs = [
{
label: tString(language, 'link_tooltip_page_anchor'),
icon: <Icon icon="arrow-down-short-wide" className="size-3" />,
},
];
resolved.subText = undefined;
}
const hasAISummary =
!isExternal &&
!isSamePage &&
'customization' in context &&
context.customization.ai?.pageLinkSummaries.enabled &&
(inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor');
const { isSamePage, isExternal, aiSummary, openInNewTabLabel, target, breadcrumbs, children } =
props;
return (
<Tooltip.Provider delayDuration={200}>
@@ -100,15 +72,15 @@ export async function InlineLinkTooltip(props: {
isExternal && 'text-sm [overflow-wrap:anywhere]'
)}
>
{resolved.icon ? (
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{resolved.icon}
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{resolved.text}</h5>
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{!isSamePage && resolved.href ? (
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
@@ -117,40 +89,35 @@ export async function InlineLinkTooltip(props: {
: null
)}
variant="blank"
href={resolved.href}
href={target.href}
target="_blank"
label={tString(language, 'open_in_new_tab')}
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{resolved.subText ? (
<p className="mt-1 text-sm text-tint">{resolved.subText}</p>
{target.subText ? (
<p className="mt-1 text-sm text-tint">{target.subText}</p>
) : null}
</div>
{hasAISummary && 'page' in context && 'page' in inline.data.ref ? (
{aiSummary ? (
<div className="border-tint-subtle border-t bg-tint p-4">
<AIPageLinkSummary
targetPageId={
resolved.page?.id ??
inline.data.ref.page ??
context.page.id
}
targetSpaceId={inline.data.ref.space ?? context.space.id}
linkTitle={getNodeText(inline)}
linkPreview={`**${resolved.text}**: ${resolved.subText}`}
showTrademark={
'customization' in context &&
context.customization.trademark.enabled
}
targetPageId={aiSummary.pageId}
targetSpaceId={aiSummary.spaceId}
showTrademark
/>
</div>
) : null}
</div>
<Tooltip.Arrow className={hasAISummary ? 'fill-tint-3' : 'fill-tint-1'} />
<Tooltip.Arrow
className={
typeof aiSummary !== 'undefined' ? 'fill-tint-3' : 'fill-tint-1'
}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
@@ -0,0 +1 @@
export * from './InlineLink';
@@ -5,8 +5,8 @@ import { GITBOOK_INTEGRATIONS_HOST } from '@v2/lib/env';
import type { BlockProps } from '../Block';
import './contentkit.css';
import { getDataOrNull } from '@v2/lib/data';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions';
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
@@ -16,8 +16,6 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
throw new Error('integration block requires a content.spaceId');
}
const { dataFetcher } = context.contentContext;
const initialInput: RenderIntegrationUI = {
componentId: block.data.block,
props: block.data.props,
@@ -30,17 +28,27 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
},
};
const initialOutput = await getDataOrNull(
dataFetcher.renderIntegrationUi({
integrationName: block.data.integration,
request: initialInput,
}),
const initialResponse = await fetchSafeIntegrationUI(context.contentContext, {
integrationName: block.data.integration,
request: initialInput,
});
// The API can respond with a 400 error if the integration is not installed
// and 404 if the integration is not found.
[404, 400]
);
if (!initialOutput || initialOutput.type === 'complete') {
if (initialResponse.error) {
if (initialResponse.error.code === 404) {
return null;
}
return (
<div className={tcls(style)}>
<pre>
Unexpected error with integration {block.data.integration}:{' '}
{initialResponse.error.message}
</pre>
</div>
);
}
const initialOutput = initialResponse.data;
if (initialOutput.type === 'complete') {
return null;
}
@@ -0,0 +1,34 @@
import type { RenderIntegrationUI } from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { ignoreDataFetcherErrors } from '@v2/lib/data';
/**
* Render an integration UI while ignoring some errors.
*/
export async function fetchSafeIntegrationUI(
context: GitBookBaseContext,
{
integrationName,
request,
}: {
integrationName: string;
request: RenderIntegrationUI;
}
) {
const output = await ignoreDataFetcherErrors(
context.dataFetcher.renderIntegrationUi({
integrationName,
request,
}),
// The API can respond with certain errors that are expected to happen.
[
404, // Integration has been uninstalled
400, // Integration is rejecting its own request
422, // Integration is triggering an invalid request, failing at the validation step
502, // Integration is failing in an unexpected way
]
);
return output;
}
@@ -4,9 +4,9 @@ import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import type { RenderIntegrationUI } from '@gitbook/api';
import { ContentKitOutput } from '@gitbook/react-contentkit';
import { throwIfDataError } from '@v2/lib/data';
import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
/**
* Server action to render an integration UI request from <ContentKit />.
@@ -22,16 +22,19 @@ export async function renderIntegrationUi({
request: RenderIntegrationUI;
}) {
const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const output = await fetchSafeIntegrationUI(serverAction, {
integrationName: renderContext.integrationName,
request,
});
const output = await throwIfDataError(
serverAction.dataFetcher.renderIntegrationUi({
integrationName: renderContext.integrationName,
request,
})
);
if (output.error) {
return {
error: output.error.message,
};
}
return {
children: <ContentKitOutput output={output} context={contentKitServerContext} />,
output: output,
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
output: output.data,
};
}
@@ -23,18 +23,18 @@ export async function RecordCard(
const coverFile = view.coverDefinition
? getRecordValue<string[]>(record[1], view.coverDefinition)?.[0]
: null;
const cover =
coverFile && context.contentContext
? await resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
: null;
const targetRef = view.targetDefinition
? (record[1].values[view.targetDefinition] as ContentRef)
: null;
const target =
const [cover, target] = await Promise.all([
coverFile && context.contentContext
? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
: null,
targetRef && context.contentContext
? await resolveContentRef(targetRef, context.contentContext)
: null;
? resolveContentRef(targetRef, context.contentContext)
: null,
]);
const coverIsSquareOrPortrait =
cover?.file?.dimensions &&
@@ -84,9 +84,13 @@ export function DynamicTabs(
);
}, [id, tabs, tabsState]);
const orientation: string = 'horizontal'; // TODO: Get orientation from tab block options
const position: string = 'start'; // TODO: Get position from tab block options
const description = null; // TODO: Get description from tabs
// To avoid issue with hydration, we only use the state from localStorage
// once the component has been mounted.
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
// and get stuck into an inconsistent state.
const mounted = useIsMounted();
const active = mounted ? activeState : tabs[0];
@@ -143,14 +147,15 @@ export function DynamicTabs(
return (
<div
className={tcls(
'rounded-lg',
'straight-corners:rounded-sm',
'ring-1',
'ring-inset',
'ring-tint-subtle',
'flex',
'flex-col',
'overflow-hidden',
'pb-1',
orientation === 'horizontal' && position === 'start' && 'flex-col',
orientation === 'vertical' && position === 'start' && 'md:flex-row',
orientation === 'vertical' && position === 'end' && 'md:flex-row-reverse',
style
)}
>
@@ -158,70 +163,37 @@ export function DynamicTabs(
role="tablist"
className={tcls(
'group/tabs',
'inline-flex',
'flex',
tabs.length > 3 ? 'md:flex-wrap' : '',
'overflow-x-auto',
'md:overflow-hidden',
'gap-x-1',
'flex-row',
'self-stretch',
'after:flex-[1]',
'after:bg-tint-12/1',
// if last tab is selected, apply rounded to :after element
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md'
orientation === 'vertical' && 'gap-1.5 md:max-w-[40%] md:flex-col',
'snap-x',
'snap-mandatory',
'-mb-px',
'peer',
orientation === 'vertical' && position === 'start' && 'md:-mr-px md:mb-0',
orientation === 'vertical' && position === 'end' && 'md:-ml-px md:mb-0'
)}
>
{tabs.map((tab) => (
<div
key={tab.id}
className={tcls(
hashLinkButtonWrapperStyles,
'flex',
'items-center',
'gap-3.5',
//prev from active-tab
'[&:has(+_.active-tab)]:rounded-br-md',
//next from active-tab
'[.active-tab_+_&]:rounded-bl-md',
//next from active-tab
'[.active-tab_+_:after]:rounded-br-md',
'after:transition-colors',
'after:border-r',
'after:absolute',
'after:left-[unset]',
'after:right-0',
'after:border-tint',
'after:top-[15%]',
'after:h-[70%]',
'after:w-[1px]',
'px-3.5',
'py-2',
'last:after:border-transparent',
'text-tint',
'bg-tint-12/1',
'hover:text-tint-strong',
'max-w-full',
'truncate',
active.id === tab.id
? [
'shrink-0',
'active-tab',
'text-tint-strong',
'bg-transparent',
'after:[&.active-tab]:border-transparent',
'after:[:has(+_&.active-tab)]:border-transparent',
'after:[:has(&_+)]:border-transparent',
]
: null
'tab -mx-4 flex overflow-hidden px-4 max-md:shrink-0 max-md:last:pr-0 [&:first-child>button]:before:hidden',
active.id === tab.id && 'active-tab z-20',
description && [
'min-w-56 max-md:max-w-[calc(33.33%+1.75rem)]',
orientation === 'horizontal' && 'md:max-w-[calc(33.33%+1.75rem)]',
]
)}
>
<button
type="button"
role="tab"
aria-orientation={orientation as 'horizontal' | 'vertical'}
aria-selected={active.id === tab.id}
aria-controls={getTabPanelId(tab.id)}
id={getTabButtonId(tab.id)}
@@ -229,23 +201,107 @@ export function DynamicTabs(
onSelectTab(tab);
}}
className={tcls(
'inline-block',
'text-sm',
'transition-[color]',
'font-[500]',
hashLinkButtonWrapperStyles,
description ? 'p-4' : 'px-4 py-1.5',
'grow',
'transition-colors',
'text-left',
'flex',
'flex-col',
'gap-1',
'rounded-md',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'focus:outline-none',
'focus-visible:ring-2',
'ring-inset',
'border',
'relative',
'max-w-full',
'truncate'
// Flared corners
'before:absolute before:size-4 before:transition-all before:content-[""] focus-visible:before:hidden',
'before:end-full',
'before:bottom-0',
'before:rounded-ee-md',
'circular-corners:before:rounded-ee-2xl',
'before:border-e before:border-b',
'before:shadow-[4px_4px_0_1px_var(--tw-shadow-color)]',
'after:absolute after:size-4 after:transition-all after:content-[""] focus-visible:after:hidden',
'after:start-full',
'after:bottom-0',
'after:rounded-es-md',
'circular-corners:after:rounded-es-2xl',
'after:border-s after:border-b',
'after:shadow-[-4px_4px_0_1px_var(--tw-shadow-color)]',
active.id === tab.id
? [
'before:border-tint-subtle after:border-tint-subtle',
'before:shadow-tint-1 after:shadow-tint-1',
]
: [
'before:border-tint-subtle after:border-tint-subtle',
'before:shadow-tint-2 after:shadow-tint-2',
'hover:after:shadow-tint-4 hover:before:shadow-tint-4',
],
orientation === 'vertical' && 'md:after:hidden md:before:hidden',
'straight-corners:before:hidden straight-corners:after:hidden',
'max-md:!rounded-b-none max-md:border-b-0',
// Position-specific adjustments
orientation === 'horizontal' &&
position === 'start' &&
'md:!rounded-b-none md:border-b-0',
orientation === 'vertical' &&
position === 'start' &&
'md:!rounded-r-none md:border-r-0',
orientation === 'vertical' &&
position === 'end' &&
'md:!rounded-l-none md:border-l-0',
'text-tint',
'snap-start',
active.id === tab.id
? ['border-tint-subtle bg-tint-base text-tint-strong']
: [
'border-tint-subtle bg-tint-subtle',
'hover:z-10 hover:border-tint-subtle hover:bg-tint-hover hover:text-tint-strong',
'focus-visible:z-10 focus-visible:bg-tint-hover focus-visible:text-tint-strong',
]
)}
>
{tab.title}
<div
className={tcls(
'flex flex-row items-center gap-1',
description ? 'font-semibold text-base' : 'font-medium text-sm'
)}
>
<div
className={tcls(
'line-clamp-2',
active.id === tab.id && 'text-primary-subtle'
)}
>
{tab.title}
</div>
<HashLinkButton
id={getTabButtonId(tab.id)}
block={block}
label="Direct link to tab"
className={tcls(
'-mt-px ml-auto',
orientation === 'vertical' || description
? 'max-md:-mr-3'
: '-mr-3'
)}
/>
</div>
{description ? (
<p className="line-clamp-5 text-sm">{description}</p>
) : null}
</button>
<HashLinkButton
id={getTabButtonId(tab.id)}
block={block}
label="Direct link to tab"
/>
</div>
))}
</div>
@@ -255,7 +311,23 @@ export function DynamicTabs(
role="tabpanel"
id={getTabPanelId(tab.id)}
aria-labelledby={getTabButtonId(tab.id)}
className={tcls('p-4', tab.id !== active.id ? 'hidden' : null)}
className={tcls(
'p-4',
'rounded-md',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
'z-10',
'bg-tint-base',
position === 'start' && '!rounded-tl-none',
position === 'end' && '!rounded-tr-none',
'transition-all',
'ring-1',
'ring-inset',
'ring-tint-subtle',
'grow',
'depth-subtle:shadow-sm',
tab.id !== active.id ? 'hidden' : null
)}
>
{tabsBody[index]}
</div>
@@ -300,7 +372,6 @@ function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null {
};
})
.filter(({ score }) => score >= 0)
// .sortBy(({ score }) => -score)
.sort(({ score: a }, { score: b }) => b - a)
.map(({ item }) => item)[0] ?? null
);
@@ -4,7 +4,7 @@ import { getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import type { GitBookSiteContext } from '@v2/lib/context';
import { DropdownChevron, DropdownMenu } from './DropdownMenu';
import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
export function SpacesDropdown(props: {
context: GitBookSiteContext;
@@ -65,17 +65,14 @@ export function SpacesDropdown(props: {
</div>
}
>
{siteSpaces.map((otherSiteSpace, index) => (
<SpacesDropdownMenuItem
key={`${otherSiteSpace.id}-${index}`}
variantSpace={{
id: otherSiteSpace.id,
title: otherSiteSpace.title,
url: getSiteSpaceURL(context, otherSiteSpace),
}}
active={otherSiteSpace.id === siteSpace.id}
/>
))}
<SpacesDropdownMenuItems
slimSpaces={siteSpaces.map((space) => ({
id: space.id,
title: space.title,
url: getSiteSpaceURL(context, space),
}))}
curPath={siteSpace.path}
/>
</DropdownMenu>
);
}
@@ -6,12 +6,28 @@ import { joinPath } from '@/lib/paths';
import { useCurrentPagePath } from '../hooks';
import { DropdownMenuItem } from './DropdownMenu';
function useVariantSpaceHref(variantSpaceUrl: string) {
interface VariantSpace {
id: Space['id'];
title: Space['title'];
url: string;
}
// When switching to a different variant space, we reconstruct the URL by swapping the space path.
function useVariantSpaceHref(variantSpaceUrl: string, currentSpacePath: string, active = false) {
const currentPathname = useCurrentPagePath();
// We need to ensure that the variant space URL is not the same as the current space path.
// If it is, we return only the variant space URL to redirect to the root of the variant space.
// This is necessary in case the currentPathname is the same as the variantSpaceUrl,
// otherwise we would redirect to the same space if the variant space that we are switching to is the default one.
if (!active && currentPathname.startsWith(`${currentSpacePath}/`)) {
return variantSpaceUrl;
}
if (URL.canParse(variantSpaceUrl)) {
const targetUrl = new URL(variantSpaceUrl);
targetUrl.pathname = joinPath(targetUrl.pathname, currentPathname);
targetUrl.searchParams.set('fallback', 'true');
return targetUrl.toString();
@@ -22,11 +38,12 @@ function useVariantSpaceHref(variantSpaceUrl: string) {
}
export function SpacesDropdownMenuItem(props: {
variantSpace: { id: Space['id']; title: Space['title']; url: string };
variantSpace: VariantSpace;
active: boolean;
currentSpacePath: string;
}) {
const { variantSpace, active } = props;
const variantHref = useVariantSpaceHref(variantSpace.url);
const { variantSpace, active, currentSpacePath } = props;
const variantHref = useVariantSpaceHref(variantSpace.url, currentSpacePath, active);
return (
<DropdownMenuItem key={variantSpace.id} href={variantHref} active={active}>
@@ -34,3 +51,23 @@ export function SpacesDropdownMenuItem(props: {
</DropdownMenuItem>
);
}
export function SpacesDropdownMenuItems(props: {
slimSpaces: VariantSpace[];
curPath: string;
}) {
const { slimSpaces, curPath } = props;
return (
<>
{slimSpaces.map((space) => (
<SpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={false}
currentSpacePath={curPath}
/>
))}
</>
);
}
@@ -53,7 +53,7 @@ export async function PDFPage(props: {
}) {
const baseContext = props.context;
const searchParams = new URLSearchParams(props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
const pdfParams = getPDFSearchParams(searchParams);
const customization =
'customization' in baseContext ? baseContext.customization : defaultCustomization();
@@ -245,6 +245,7 @@ async function PDFPageDocument(props: {
page,
},
getId: (id) => getPagePDFContainerId(page, id),
shouldRenderLinkPreviews: false, // We don't want to render link previews in the PDF.
}}
// We consider all pages as offscreen in PDF mode
// to ensure we can efficiently render as many pages as possible
@@ -4,7 +4,7 @@ import React from 'react';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
import { hasFullWidthBlock, hasMoreThan, isNodeEmpty } from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
@@ -17,6 +17,8 @@ import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { PreservePageLayout } from './PreservePageLayout';
const LINK_PREVIEW_MAX_COUNT = 100;
export function PageBody(props: {
context: GitBookSiteContext;
page: RevisionPageDocument;
@@ -28,6 +30,15 @@ export function PageBody(props: {
const { customization } = context;
const contentFullWidth = document ? hasFullWidthBlock(document) : false;
// Render link previews only if there are less than LINK_PREVIEW_MAX_COUNT links in the document.
const shouldRenderLinkPreviews = document
? !hasMoreThan(
document,
(inline) => inline.object === 'inline' && inline.type === 'link',
LINK_PREVIEW_MAX_COUNT
)
: false;
const pageFullWidth = page.id === 'wtthNFMqmEQmnt5LKR0q';
const asFullWidth = pageFullWidth || contentFullWidth;
const language = getSpaceLanguage(customization);
@@ -68,6 +79,7 @@ export function PageBody(props: {
context={{
mode: 'default',
contentContext: context,
shouldRenderLinkPreviews,
}}
/>
</React.Suspense>
@@ -21,8 +21,10 @@ export async function PageCover(props: {
context: GitBookSiteContext;
}) {
const { as, page, cover, context } = props;
const resolved = cover.ref ? await resolveContentRef(cover.ref, context) : null;
const resolvedDark = cover.refDark ? await resolveContentRef(cover.refDark, context) : null;
const [resolved, resolvedDark] = await Promise.all([
cover.ref ? resolveContentRef(cover.ref, context) : null,
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
]);
return (
<div
@@ -66,11 +66,7 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
<HighlightQuery query={query} text={item.title} />
</p>
) : null}
{item.body ? (
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
<HighlightQuery query={query} text={item.body} />
</p>
) : null}
{item.body ? highlightQueryInBody(item.body, query) : null}
</div>
<div
className={tcls(
@@ -90,3 +86,14 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
</Link>
);
});
function highlightQueryInBody(body: string, query: string) {
const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
// Ensure the query to be highlighted is visible in the body.
return (
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
<HighlightQuery query={query} text={idx < 20 ? body : `...${body.slice(idx - 10)}`} />
</p>
);
}
@@ -345,6 +345,7 @@ async function transformAnswer(
mode: 'default',
contentContext: undefined,
wrapBlocksInSuspense: false,
shouldRenderLinkPreviews: false, // We don't want to render link previews in the AI answer.
}}
style={['space-y-5']}
/>
@@ -102,30 +102,37 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
const faviconSize = 48;
const icons = [
{
url: customIcon?.light
? await getResizedImageURL(imageResizer, customIcon.light, {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(
linker.toPathInSpace('~gitbook/icon?size=small&theme=light')
),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url: customIcon?.dark
? await getResizedImageURL(imageResizer, customIcon.dark, {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(linker.toPathInSpace('~gitbook/icon?size=small&theme=dark')),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
];
const icons = await Promise.all(
[
{
url: customIcon?.light
? getResizedImageURL(imageResizer, customIcon.light, {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(
linker.toPathInSpace('~gitbook/icon?size=small&theme=light')
),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url: customIcon?.dark
? getResizedImageURL(imageResizer, customIcon.dark, {
width: faviconSize,
height: faviconSize,
})
: linker.toAbsoluteURL(
linker.toPathInSpace('~gitbook/icon?size=small&theme=dark')
),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
].map(async (icon) => ({
...icon,
url: await icon.url,
}))
);
return {
title: site.title,
@@ -1,6 +1,7 @@
import { getSectionURL, getSiteSpaceURL } from '@/lib/sites';
import type { SiteSection, SiteSectionGroup } from '@gitbook/api';
import type { SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api';
import type { GitBookSiteContext, SiteSections } from '@v2/lib/context';
import assertNever from 'assert-never';
export type ClientSiteSections = {
list: (ClientSiteSection | ClientSiteSectionGroup)[];
@@ -27,16 +28,32 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections:
const clientSections: (ClientSiteSection | ClientSiteSectionGroup)[] = [];
for (const item of list) {
if (item.object === 'site-section-group') {
clientSections.push({
id: item.id,
title: item.title,
icon: item.icon,
object: item.object,
sections: item.sections.map((section) => encodeSection(context, section)),
});
} else {
clientSections.push(encodeSection(context, item));
switch (item.object) {
case 'site-section-group': {
const sections = item.sections
.filter((section) => shouldIncludeSection(context, section))
.map((section) => encodeSection(context, section));
// Skip empty groups
if (sections.length === 0) {
continue;
}
clientSections.push({
id: item.id,
title: item.title,
icon: item.icon,
object: item.object,
sections,
});
continue;
}
case 'site-section': {
clientSections.push(encodeSection(context, item));
continue;
}
default:
assertNever(item, 'Unknown site section object type');
}
}
@@ -57,6 +74,33 @@ function encodeSection(context: GitBookSiteContext, section: SiteSection) {
};
}
/**
* Test if a section should be included in the list of sections.
*/
function shouldIncludeSection(context: GitBookSiteContext, section: SiteSection) {
if (context.site.id !== 'site_JOVzv') {
return true;
}
// Testing for a new mode of navigation where the multi-variants section are hidden
// if they do not include an equivalent of the current site space.
// TODO: replace with a proper flag on the section
const withNavigateOnlyIfEquivalent = section.id === 'sitesc_4jvEm';
if (!withNavigateOnlyIfEquivalent) {
return true;
}
const { siteSpace: currentSiteSpace } = context;
if (section.siteSpaces.length === 1) {
return true;
}
return section.siteSpaces.some((siteSpace) =>
areSiteSpacesEquivalent(siteSpace, currentSiteSpace)
);
}
/**
* Find the best default site space to navigate to for a givent section:
* 1. If we are on the default, continue on the default.
@@ -70,8 +114,8 @@ function findBestTargetURL(context: GitBookSiteContext, section: SiteSection) {
return getSectionURL(context, section);
}
const bestMatch = section.siteSpaces.find(
(siteSpace) => siteSpace.path === currentSiteSpace.path
const bestMatch = section.siteSpaces.find((siteSpace) =>
areSiteSpacesEquivalent(siteSpace, currentSiteSpace)
);
if (bestMatch) {
return getSiteSpaceURL(context, bestMatch);
@@ -79,3 +123,10 @@ function findBestTargetURL(context: GitBookSiteContext, section: SiteSection) {
return getSectionURL(context, section);
}
/**
* Test if 2 site spaces are equivalent.
*/
function areSiteSpacesEquivalent(siteSpace1: SiteSpace, siteSpace2: SiteSpace) {
return siteSpace1.path === siteSpace2.path;
}
+35
View File
@@ -30,6 +30,41 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
return false;
}
/**
* Returns true if the document has more than `limit` blocks and/or inlines that match the `check` predicate.
*/
export function hasMoreThan(
document: JSONDocument | DocumentBlock,
check: (block: DocumentBlock | DocumentInline) => boolean,
limit = 1
): boolean {
let count = 0;
function traverse(node: JSONDocument | DocumentBlock | DocumentFragment): boolean {
for (const child of 'nodes' in node ? node.nodes : []) {
if (child.object === 'text') continue;
if (check(child)) {
count++;
if (count > limit) return true;
}
if (child.object === 'block' && 'nodes' in child) {
if (traverse(child)) return true;
}
if (child.object === 'block' && 'fragments' in child) {
for (const fragment of child.fragments) {
if (traverse(fragment)) return true;
}
}
}
return false;
}
return traverse(document);
}
/**
* Get the text of a block/inline.
*/
+17 -11
View File
@@ -1,5 +1,6 @@
import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types';
import { getCloudflareContext as getCloudflareContextV2 } from '@v2/lib/data/cloudflare';
import { GITBOOK_RUNTIME } from '@v2/lib/env';
import { isV2 } from './v2';
let pendings: Array<Promise<unknown>> = [];
@@ -49,20 +50,25 @@ export async function waitUntil(promise: Promise<unknown>) {
return;
}
if (isV2()) {
const context = getCloudflareContextV2();
if (context) {
context.ctx.waitUntil(promise);
return;
if (GITBOOK_RUNTIME === 'cloudflare') {
if (isV2()) {
const context = getCloudflareContextV2();
if (context) {
context.ctx.waitUntil(promise);
return;
}
} else {
const cloudflareContext = await getGlobalContext();
if ('waitUntil' in cloudflareContext) {
cloudflareContext.waitUntil(promise);
return;
}
}
}
const cloudflareContext = await getGlobalContext();
if ('waitUntil' in cloudflareContext) {
cloudflareContext.waitUntil(promise);
} else {
await promise;
}
await promise.catch((error) => {
console.error('Ignored error in waitUntil', error);
});
}
/**
+37 -16
View File
@@ -24,6 +24,11 @@ const SIZES = {
},
};
type RenderIconOptions = {
size: keyof typeof SIZES;
theme: 'light' | 'dark';
};
/**
* Generate an icon for a site content.
*/
@@ -31,7 +36,7 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) {
const options = getOptions(req.url);
const size = SIZES[options.size];
const { site, customization } = context;
const { customization } = context;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
// If the site has a custom icon, redirect to it
@@ -45,17 +50,45 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) {
);
}
return new ImageResponse(<SiteDefaultIcon context={context} options={options} />, {
width: size.width,
height: size.height,
headers: {
'cache-tag': [
getCacheTag({
tag: 'site',
site: context.site.id,
}),
].join(','),
},
});
}
/**
* Render the icon as a React node.
*/
export function SiteDefaultIcon(props: {
context: GitBookSiteContext;
options: RenderIconOptions;
style?: React.CSSProperties;
tw?: string;
}) {
const { context, options, style, tw } = props;
const size = SIZES[options.size];
const { site, customization } = context;
const contentTitle = site.title;
return new ImageResponse(
return (
<div
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle)}
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle, tw)}
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...style,
}}
>
<h2
@@ -70,19 +103,7 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) {
? getEmojiForCode(customization.favicon.emoji)
: contentTitle.slice(0, 1).toUpperCase()}
</h2>
</div>,
{
width: size.width,
height: size.height,
headers: {
'cache-tag': [
getCacheTag({
tag: 'site',
site: context.site.id,
}),
].join(','),
},
}
</div>
);
}
+149 -83
View File
@@ -1,23 +1,33 @@
import { CustomizationDefaultFont, CustomizationHeaderPreset } from '@gitbook/api';
import { colorContrast } from '@gitbook/colors';
import { type FontWeight, getDefaultFont } from '@gitbook/fonts';
import { direction } from 'direction';
import { imageSize } from 'image-size';
import { redirect } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { type PageParams, fetchPageData } from '@/components/SitePage';
import { getFontSourcesToPreload } from '@/fonts/custom';
import { getAssetURL } from '@/lib/assets';
import { getExtension } from '@/lib/paths';
import { filterOutNullable } from '@/lib/typescript';
import { getCacheTag } from '@gitbook/cache-tags';
import type { GitBookSiteContext } from '@v2/lib/context';
import { getResizedImageURL } from '@v2/lib/images';
import {
type ResizeImageOptions,
SizableImageAction,
checkIsSizableImageURL,
getResizedImageURL,
resizeImage,
} from '@v2/lib/images';
import { SiteDefaultIcon } from './icon';
/**
* Render the OpenGraph image for a site content.
*/
export async function serveOGImage(baseContext: GitBookSiteContext, params: PageParams) {
const { context, pageTarget } = await fetchPageData(baseContext, params);
const { customization, site, linker, imageResizer } = context;
const { customization, site, imageResizer } = context;
const page = pageTarget?.page;
// If user configured a custom social preview, we redirect to it.
@@ -31,7 +41,6 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
}
// Compute all text to load only the necessary fonts
const contentTitle = customization.header.logo ? '' : site.title;
const pageTitle = page
? page.title.length > 64
? `${page.title.slice(0, 64)}...`
@@ -45,13 +54,13 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
: '';
// Load the fonts
const { fontFamily, fonts } = await (async () => {
const fontLoader = async () => {
// google fonts
if (typeof customization.styling.font === 'string') {
const fontFamily = customization.styling.font ?? CustomizationDefaultFont.Inter;
const regularText = pageDescription;
const boldText = `${contentTitle}${pageTitle}`;
const boldText = `${site.title} ${pageTitle}`;
const fonts = (
await Promise.all([
@@ -85,7 +94,7 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
).filter(filterOutNullable);
return { fontFamily: 'CustomFont', fonts };
})();
};
const theme = customization.themes.default;
const useLightTheme = theme === 'light';
@@ -139,32 +148,55 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
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(Number.parseInt(`0x${customization.favicon.emoji}`))}
</span>
);
const src = await readSelfImage(
linker.toAbsoluteURL(
linker.toPathInSpace(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`
)
)
const faviconLoader = async () => {
if (customization.header.logo) {
// Don't load the favicon if we have a logo
// as it'll not be used.
return null;
}
const faviconSize = {
width: 48,
height: 48,
};
if ('icon' in customization.favicon) {
const faviconImage = await fetchImage(customization.favicon.icon[theme], faviconSize);
if (faviconImage) {
return <img {...faviconImage} {...faviconSize} alt="Icon" />;
}
}
return (
<SiteDefaultIcon
context={context}
options={{
size: 'small',
theme,
}}
style={faviconSize}
/>
);
return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />;
})();
};
const logoLoader = async () => {
if (!customization.header.logo) {
return null;
}
return await fetchImage(
useLightTheme ? customization.header.logo.light : customization.header.logo.dark,
{
height: 60,
}
);
};
const [favicon, logo, { fontFamily, fonts }] = await Promise.all([
faviconLoader(),
logoLoader(),
fontLoader(),
]);
return new ImageResponse(
<div
@@ -185,25 +217,19 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
{/* Grid */}
<img
tw="absolute inset-0 w-[100vw] h-[100vh]"
src={await readStaticImage(gridAsset)}
src={(await fetchStaticImage(gridAsset)).src}
alt="Grid"
/>
{/* Logo */}
{customization.header.logo ? (
<img
alt="Logo"
height={60}
src={
useLightTheme
? customization.header.logo.light
: customization.header.logo.dark
}
/>
{logo ? (
<div tw="flex flex-row">
<img {...logo} alt="Logo" tw="h-[60px]" />
</div>
) : (
<div tw="flex">
<div tw="flex flex-row items-center">
{favicon}
<h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3>
<h3 tw="text-4xl ml-4 my-0 font-bold">{transformText(site.title)}</h3>
</div>
)}
@@ -212,10 +238,12 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
<h1
tw={`text-8xl my-0 tracking-tight leading-none text-left text-[${colors.title}] font-bold`}
>
{pageTitle}
{transformText(pageTitle)}
</h1>
{pageDescription ? (
<h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">{pageDescription}</h2>
<h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">
{transformText(pageDescription)}
</h2>
) : null}
</div>
</div>,
@@ -287,38 +315,12 @@ async function loadCustomFont(input: { url: string; weight: 400 | 700 }) {
};
}
/**
* Temporary function to log some data on Cloudflare.
* TODO: remove this when we found the issue
*/
function logOnCloudflareOnly(message: string) {
if (process.env.DEBUG_CLOUDFLARE === 'true') {
// biome-ignore lint/suspicious/noConsole: <explanation>
console.log(message);
}
}
/**
* Read an image from a response as a base64 encoded string.
*/
async function readImage(response: Response) {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.startsWith('image/')) {
logOnCloudflareOnly(`Invalid content type: ${contentType},
status: ${response.status}
rayId: ${response.headers.get('cf-ray')}`);
throw new Error(`Invalid content type: ${contentType}`);
}
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
return `data:${contentType};base64,${base64}`;
}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const staticCache = new Map<string, any>();
// Do we need to limit the in-memory cache size? I think given the usage, we should be fine.
/**
* Get or initialize a value in the static cache.
*/
async function getWithCache<T>(key: string, fn: () => Promise<T>) {
const cached = staticCache.get(key) as T;
if (cached) {
@@ -333,16 +335,80 @@ async function getWithCache<T>(key: string, fn: () => Promise<T>) {
/**
* Read a static image and cache it in memory.
*/
async function readStaticImage(url: string) {
logOnCloudflareOnly(`Reading static image: ${url}, cache size: ${staticCache.size}`);
return getWithCache(`static-image:${url}`, () => readSelfImage(url));
async function fetchStaticImage(url: string) {
return getWithCache(`static-image:${url}`, async () => {
const image = await fetchImage(url);
if (!image) {
throw new Error('Failed to fetch static image');
}
return image;
});
}
/**
* Read an image from GitBook itself.
* @vercel/og supports the following image formats:
* Extracted from https://github.com/vercel/next.js/blob/canary/packages/next/src/compiled/%40vercel/og/index.node.js
*/
async function readSelfImage(url: string) {
const response = await fetch(url);
const image = await readImage(response);
return image;
const UNSUPPORTED_IMAGE_EXTENSIONS = ['.avif', '.webp'];
const SUPPORTED_IMAGE_TYPES = [
'image/png',
'image/apng',
'image/jpeg',
'image/gif',
'image/svg+xml',
];
/**
* Fetch an image from a URL and return a base64 encoded string.
* We do this as @vercel/og is otherwise failing on SVG images referenced by a URL.
*/
async function fetchImage(url: string, options?: ResizeImageOptions) {
// Skip early some images to avoid fetching them
const parsedURL = new URL(url);
if (UNSUPPORTED_IMAGE_EXTENSIONS.includes(getExtension(parsedURL.pathname).toLowerCase())) {
return null;
}
// We use the image resizer to normalize the image format to PNG.
// as @vercel/og can sometimes fail on some JPEG images.
const response =
checkIsSizableImageURL(url) !== SizableImageAction.Resize
? await fetch(url)
: await resizeImage(url, {
...options,
format: 'png',
});
// Filter out unsupported image types
const contentType = response.headers.get('content-type');
if (!contentType || !SUPPORTED_IMAGE_TYPES.some((type) => contentType.includes(type))) {
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const base64 = buffer.toString('base64');
const src = `data:${contentType};base64,${base64}`;
try {
const { width, height } = imageSize(buffer);
return { src, width, height };
} catch {
return null;
}
}
/**
* @vercel/og doesn't support RTL text, so we need to transform with a HACK for now.
* We can remove it once support has been added.
* https://github.com/vercel/satori/issues/74
*/
function transformText(text: string) {
const dir = direction(text);
if (dir !== 'rtl') {
return text;
}
return '';
}
+26 -15
View File
@@ -38,10 +38,18 @@ export function ContentKit<RenderContext>(props: {
render: (input: {
renderContext: RenderContext;
request: RequestRenderIntegrationUI;
}) => Promise<{
children: React.ReactNode;
output: ContentKitRenderOutput;
}>;
}) => Promise<
| {
error?: undefined;
children: React.ReactNode;
output: ContentKitRenderOutput;
}
| {
error: string;
children?: undefined;
output?: undefined;
}
>;
/** Callback when an action is triggered */
onAction?: (action: ContentKitAction) => void;
/** Callback when the flow is completed */
@@ -98,17 +106,18 @@ export function ContentKit<RenderContext>(props: {
request: newInput,
});
const output = result.output;
if (output) {
if (output.type === 'complete') {
return onComplete?.(output.returnValue);
}
if (output.type === 'complete') {
return onComplete?.(output.returnValue);
setCurrent((prev) => ({
input: newInput,
children: result.children,
output: output,
state: prev.state,
}));
}
setCurrent((prev) => ({
input: newInput,
children: result.children,
output: output,
state: prev.state,
}));
},
[setCurrent, current, render, onComplete]
);
@@ -147,8 +156,10 @@ export function ContentKit<RenderContext>(props: {
renderContext,
request: modalInput,
});
if (result.output.type === 'element' || !result.output.type) {
if (
result.output &&
(result.output.type === 'element' || !result.output.type)
) {
setSubView({
mode: 'modal',
initialInput: modalInput,