mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 06:35:17 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44fb83655b | |||
| d0b93fcae0 | |||
| 2e5d9418d4 | |||
| 1000978049 | |||
| b475655e7a | |||
| 73e0cbb2d6 | |||
| 392f59450c | |||
| b4918f60ce | |||
| 8f7c304d58 | |||
| a3a944d7dd | |||
| 88a35ed057 | |||
| 67998b6f15 | |||
| ff6d1150a5 | |||
| 500c8cb649 | |||
| 6859f7d239 | |||
| dfa8a37be1 | |||
| 11a6511b7a | |||
| af98402655 | |||
| b7a0db3339 | |||
| 382a19885b | |||
| d99da6a3ae | |||
| 72cd0e59e6 | |||
| 42d88da73c | |||
| 2863fe0dc1 | |||
| 87218062be | |||
| 8a3910e208 | |||
| 6294bbb53c | |||
| a28a997851 | |||
| e2dd8a9288 | |||
| 17df1be3ef | |||
| 34a11d37a7 | |||
| 28a9ee701e | |||
| 50e8b2ec52 | |||
| 315717fa2d | |||
| da67711c90 | |||
| 60015f5807 | |||
| 8e3e5147cf | |||
| 8e75e3bec5 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix UX issue about highlighting the search term in search result sections
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add support for inline icons.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix crash when integration script fails to render block.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Optimize performances by using a smarter per-request cache arround data cached functions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Fix rendering of ogimage with SVG logos.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix crash when integration is triggering invalid requests.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-contentkit": patch
|
||||
---
|
||||
|
||||
Add basic error handling when transitioning between states.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Don't crash ogimage generation on RTL text, as a workaround until we can support it.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix rendering of ogimage when logo or icon are AVIF images.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add margin to adjacent buttons
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix ogimage generation failing with some JPEG images.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Don't cache unexpected API errors for more than a few minutes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Fix an issue where PDF export URLs were not keeping their query params.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
add a global error boundary
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Ignore case while highlighting search results.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Fix InlineLinkTooltip having a negative impact on performance, especially on larger pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix crash during rendering of ogimage for VA sites with default icon.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
New design for tab blocks
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -36,7 +36,7 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.120.0"
|
||||
"@gitbook/api": "^0.121.0"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -603,6 +603,11 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/emojis',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Icons',
|
||||
url: 'blocks/icons',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Links',
|
||||
url: 'blocks/links',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+34
-67
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user