mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-20 17:43:24 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6edf3b78f0 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix UX issue about highlighting the search term in search result sections
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add support for inline icons.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix crash when integration script fails to render block.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Optimize performances by using a smarter per-request cache arround data cached functions
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Fix rendering of ogimage with SVG logos.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-contentkit": patch
|
||||
---
|
||||
|
||||
Add basic error handling when transitioning between states.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add margin to adjacent buttons
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
Don't cache unexpected API errors for more than a few minutes.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook-v2": patch
|
||||
---
|
||||
|
||||
add a global error boundary
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Ignore case while highlighting search results.
|
||||
@@ -56,19 +56,13 @@ git clone https://github.com/gitbookIO/gitbook.git
|
||||
bun install
|
||||
```
|
||||
|
||||
4. Run build.
|
||||
|
||||
```
|
||||
bun build:v2
|
||||
```
|
||||
|
||||
5. Start your local development server.
|
||||
4. Start your local development server.
|
||||
|
||||
```
|
||||
bun dev:v2
|
||||
```
|
||||
|
||||
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||
|
||||
examples:
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@
|
||||
"classnames": "^2.5.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",
|
||||
@@ -170,7 +169,6 @@
|
||||
"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",
|
||||
@@ -285,7 +283,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.121.0",
|
||||
"@gitbook/api": "^0.120.0",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
||||
@@ -650,7 +648,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.121.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-o4/N24RM0Rg8S/3yPDjPmt6TbQF+1iZmg9q9QKxOxMqpQ2bZmMUqS7dSkeqEbEBMALx/m/x0xQlJbEJGbOwteg=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.120.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-FiRmPiSBwobMxmNjd14QkkOdM95BAPLDDRShgpS9Vsd8lHjNMyZfrJKVJTsJUuFcgYoi4cqNw9yu/TiUBUgv3g=="],
|
||||
|
||||
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
|
||||
|
||||
@@ -2068,8 +2066,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -2442,8 +2438,6 @@
|
||||
|
||||
"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.121.0"
|
||||
"@gitbook/api": "^0.120.0"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"warn-once": "^0.1.1",
|
||||
"object-identity": "^0.1.2"
|
||||
"warn-once": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gitbook": "*",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
import { GitBookAPIError } from '@gitbook/api';
|
||||
import { unstable_cacheLife as cacheLife } from 'next/cache';
|
||||
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
|
||||
|
||||
export class DataFetcherError extends Error {
|
||||
@@ -48,7 +47,11 @@ export function getDataOrNull<T>(
|
||||
return response.then((result) => getDataOrNull(result, ignoreErrors));
|
||||
}
|
||||
|
||||
return ignoreDataFetcherErrors(response, ignoreErrors).data ?? null;
|
||||
if (response.error) {
|
||||
if (ignoreErrors.includes(response.error.code)) return null;
|
||||
throw new DataFetcherError(response.error.message, response.error.code);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,50 +93,6 @@ 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 { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
|
||||
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
|
||||
import { apiClient } from './api';
|
||||
import { getExposableError } from './errors';
|
||||
import type { DataFetcherResponse } from './types';
|
||||
@@ -18,32 +18,85 @@ 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 lookupPublishedContentByUrl(
|
||||
input: LookupPublishedContentByUrlInput
|
||||
): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
||||
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>> {
|
||||
const lookupURL = new URL(input.url);
|
||||
const url = stripURLSearch(lookupURL);
|
||||
const lookup = getURLLookupAlternatives(url);
|
||||
|
||||
const result = await race(lookup.urls, async (alternative, { 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 }
|
||||
)
|
||||
)
|
||||
);
|
||||
const callResult = await input.fetchLookupAPIResult({
|
||||
url: alternative.url,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (callResult.error) {
|
||||
if (alternative.primary) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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;
|
||||
}
|
||||
@@ -42,19 +42,16 @@ export async function getPageDocument(
|
||||
}
|
||||
|
||||
// Pre-fetch the document to start filling the cache before we migrate to this API.
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// Disable for now to investigate side-effects
|
||||
if (isInPercentRollout(space.id, 10) || process.env.VERCEL_ENV === 'preview') {
|
||||
await waitUntil(
|
||||
getDataOrNull(
|
||||
dataFetcher.getRevisionPageDocument({
|
||||
spaceId: space.id,
|
||||
revisionId: space.revision,
|
||||
pageId: page.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
if (isInPercentRollout(space.id, 10)) {
|
||||
await waitUntil(
|
||||
getDataOrNull(
|
||||
dataFetcher.getRevisionPageDocument({
|
||||
spaceId: space.id,
|
||||
revisionId: space.revision,
|
||||
pageId: page.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -16,9 +16,10 @@ 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';
|
||||
@@ -33,6 +34,17 @@ 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);
|
||||
@@ -91,8 +103,11 @@ 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(
|
||||
lookupPublishedContentByUrl({
|
||||
resolve({
|
||||
url: siteRequestURL.toString(),
|
||||
visitorPayload: {
|
||||
jwtToken: visitorToken?.token ?? undefined,
|
||||
|
||||
@@ -603,11 +603,6 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/emojis',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Icons',
|
||||
url: 'blocks/icons',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Links',
|
||||
url: 'blocks/links',
|
||||
|
||||
@@ -81,8 +81,7 @@
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-to-json-schema": "^3.24.5",
|
||||
"zustand": "^5.0.3",
|
||||
"image-size": "^2.0.2"
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.3",
|
||||
|
||||
@@ -19,14 +19,14 @@ export async function AdClassicRendering({
|
||||
insightsAd: SiteInsightsAd | null;
|
||||
context: GitBookBaseContext;
|
||||
}) {
|
||||
const [smallImgSrc, logoSrc] = await Promise.all([
|
||||
const smallImgSrc =
|
||||
'smallImage' in ad
|
||||
? getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 })
|
||||
: null,
|
||||
? await getResizedImageURL(context.imageResizer, ad.smallImage, { width: 192, dpr: 2 })
|
||||
: null;
|
||||
const logoSrc =
|
||||
'logo' in ad
|
||||
? getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 })
|
||||
: null,
|
||||
]);
|
||||
? await getResizedImageURL(context.imageResizer, ad.logo, { width: 192 - 48, dpr: 2 })
|
||||
: null;
|
||||
return (
|
||||
<Link
|
||||
rel="sponsored noopener"
|
||||
|
||||
@@ -43,12 +43,10 @@ interface FetchPlaceholderAdOptions {
|
||||
* and properly access user-agent and IP.
|
||||
*/
|
||||
export async function renderAd(options: FetchAdOptions) {
|
||||
const [context, result] = await Promise.all([
|
||||
isV2() ? getServerActionBaseContext() : getV1BaseContext(),
|
||||
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
||||
]);
|
||||
const context = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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 = {
|
||||
@@ -264,28 +263,16 @@ function getPlainCodeBlockLine(
|
||||
if (node.object === 'text') {
|
||||
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
|
||||
} else {
|
||||
switch (node.type) {
|
||||
case 'annotation': {
|
||||
const start = index + content.length;
|
||||
content += getPlainCodeBlockLine(node, index + content.length, inlines);
|
||||
const end = index + content.length;
|
||||
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,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'expression': {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
nullIfNever(node);
|
||||
break;
|
||||
}
|
||||
if (inlines) {
|
||||
inlines.push({
|
||||
inline: node,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -48,8 +47,6 @@ 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,22 +17,18 @@ export async function InlineButton(props: InlineProps<api.DocumentInlineButton>)
|
||||
}
|
||||
|
||||
return (
|
||||
// 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>
|
||||
<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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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]" />;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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>) {
|
||||
@@ -31,28 +32,30 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||
const isExternal = inline.data.ref.kind === 'url';
|
||||
|
||||
return (
|
||||
<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"
|
||||
<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]}
|
||||
/>
|
||||
) : null}
|
||||
</StyledLink>
|
||||
{isExternal ? (
|
||||
<Icon
|
||||
icon="arrow-up-right"
|
||||
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
|
||||
/>
|
||||
) : null}
|
||||
</StyledLink>
|
||||
</InlineLinkTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +16,8 @@ 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,
|
||||
@@ -28,27 +30,17 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
},
|
||||
};
|
||||
|
||||
const initialResponse = await fetchSafeIntegrationUI(context.contentContext, {
|
||||
integrationName: block.data.integration,
|
||||
request: initialInput,
|
||||
});
|
||||
const initialOutput = await getDataOrNull(
|
||||
dataFetcher.renderIntegrationUi({
|
||||
integrationName: block.data.integration,
|
||||
request: initialInput,
|
||||
}),
|
||||
|
||||
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') {
|
||||
// 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') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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 a 400 error if the integration is not installed
|
||||
// and 404 if the integration is not found.
|
||||
// The API can also respond with a 502 error if the integration is not generating a proper response.
|
||||
[404, 400, 502]
|
||||
);
|
||||
|
||||
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,19 +22,16 @@ export async function renderIntegrationUi({
|
||||
request: RenderIntegrationUI;
|
||||
}) {
|
||||
const serverAction = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const output = await fetchSafeIntegrationUI(serverAction, {
|
||||
integrationName: renderContext.integrationName,
|
||||
request,
|
||||
});
|
||||
|
||||
if (output.error) {
|
||||
return {
|
||||
error: output.error.message,
|
||||
};
|
||||
}
|
||||
const output = await throwIfDataError(
|
||||
serverAction.dataFetcher.renderIntegrationUi({
|
||||
integrationName: renderContext.integrationName,
|
||||
request,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
children: <ContentKitOutput output={output.data} context={contentKitServerContext} />,
|
||||
output: output.data,
|
||||
children: <ContentKitOutput output={output} context={contentKitServerContext} />,
|
||||
output: output,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 [cover, target] = await Promise.all([
|
||||
coverFile && context.contentContext
|
||||
? resolveContentRef({ kind: 'file', file: coverFile }, context.contentContext)
|
||||
: null,
|
||||
const target =
|
||||
targetRef && context.contentContext
|
||||
? resolveContentRef(targetRef, context.contentContext)
|
||||
: null,
|
||||
]);
|
||||
? await resolveContentRef(targetRef, context.contentContext)
|
||||
: null;
|
||||
|
||||
const coverIsSquareOrPortrait =
|
||||
cover?.file?.dimensions &&
|
||||
|
||||
@@ -21,10 +21,8 @@ export async function PageCover(props: {
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { as, page, cover, context } = props;
|
||||
const [resolved, resolvedDark] = await Promise.all([
|
||||
cover.ref ? resolveContentRef(cover.ref, context) : null,
|
||||
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
|
||||
]);
|
||||
const resolved = cover.ref ? await resolveContentRef(cover.ref, context) : null;
|
||||
const resolvedDark = cover.refDark ? await resolveContentRef(cover.refDark, context) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -66,7 +66,11 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
||||
<HighlightQuery query={query} text={item.title} />
|
||||
</p>
|
||||
) : null}
|
||||
{item.body ? highlightQueryInBody(item.body, query) : null}
|
||||
{item.body ? (
|
||||
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
|
||||
<HighlightQuery query={query} text={item.body} />
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -86,14 +90,3 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,37 +102,30 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||
|
||||
const faviconSize = 48;
|
||||
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,
|
||||
}))
|
||||
);
|
||||
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)',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
title: site.title,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getSectionURL, getSiteSpaceURL } from '@/lib/sites';
|
||||
import type { SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api';
|
||||
import type { SiteSection, SiteSectionGroup } from '@gitbook/api';
|
||||
import type { GitBookSiteContext, SiteSections } from '@v2/lib/context';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
export type ClientSiteSections = {
|
||||
list: (ClientSiteSection | ClientSiteSectionGroup)[];
|
||||
@@ -28,32 +27,16 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections:
|
||||
const clientSections: (ClientSiteSection | ClientSiteSectionGroup)[] = [];
|
||||
|
||||
for (const item of list) {
|
||||
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');
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,33 +57,6 @@ 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.
|
||||
@@ -114,8 +70,8 @@ function findBestTargetURL(context: GitBookSiteContext, section: SiteSection) {
|
||||
return getSectionURL(context, section);
|
||||
}
|
||||
|
||||
const bestMatch = section.siteSpaces.find((siteSpace) =>
|
||||
areSiteSpacesEquivalent(siteSpace, currentSiteSpace)
|
||||
const bestMatch = section.siteSpaces.find(
|
||||
(siteSpace) => siteSpace.path === currentSiteSpace.path
|
||||
);
|
||||
if (bestMatch) {
|
||||
return getSiteSpaceURL(context, bestMatch);
|
||||
@@ -123,10 +79,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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>> = [];
|
||||
@@ -50,25 +49,20 @@ export async function waitUntil(promise: Promise<unknown>) {
|
||||
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;
|
||||
}
|
||||
if (isV2()) {
|
||||
const context = getCloudflareContextV2();
|
||||
if (context) {
|
||||
context.ctx.waitUntil(promise);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await promise.catch((error) => {
|
||||
console.error('Ignored error in waitUntil', error);
|
||||
});
|
||||
const cloudflareContext = await getGlobalContext();
|
||||
if ('waitUntil' in cloudflareContext) {
|
||||
cloudflareContext.waitUntil(promise);
|
||||
} else {
|
||||
await promise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CustomizationDefaultFont, CustomizationHeaderPreset } from '@gitbook/api';
|
||||
import { colorContrast } from '@gitbook/colors';
|
||||
import { type FontWeight, getDefaultFont } from '@gitbook/fonts';
|
||||
import { imageSize } from 'image-size';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
@@ -46,7 +45,7 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
|
||||
: '';
|
||||
|
||||
// Load the fonts
|
||||
const fontLoader = async () => {
|
||||
const { fontFamily, fonts } = await (async () => {
|
||||
// google fonts
|
||||
if (typeof customization.styling.font === 'string') {
|
||||
const fontFamily = customization.styling.font ?? CustomizationDefaultFont.Inter;
|
||||
@@ -86,7 +85,7 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
|
||||
).filter(filterOutNullable);
|
||||
|
||||
return { fontFamily: 'CustomFont', fonts };
|
||||
};
|
||||
})();
|
||||
|
||||
const theme = customization.themes.default;
|
||||
const useLightTheme = theme === 'light';
|
||||
@@ -140,7 +139,7 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
|
||||
break;
|
||||
}
|
||||
|
||||
const faviconLoader = async () => {
|
||||
const favicon = await (async () => {
|
||||
if ('icon' in customization.favicon)
|
||||
return (
|
||||
<img
|
||||
@@ -157,17 +156,15 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
|
||||
{String.fromCodePoint(Number.parseInt(`0x${customization.favicon.emoji}`))}
|
||||
</span>
|
||||
);
|
||||
const iconImage = await fetchImage(
|
||||
const src = await readSelfImage(
|
||||
linker.toAbsoluteURL(
|
||||
linker.toPathInSpace(
|
||||
`~gitbook/icon?size=medium&theme=${customization.themes.default}`
|
||||
)
|
||||
)
|
||||
);
|
||||
return <img {...iconImage} alt="Icon" width={40} height={40} tw="mr-4" />;
|
||||
};
|
||||
|
||||
const [favicon, { fontFamily, fonts }] = await Promise.all([faviconLoader(), fontLoader()]);
|
||||
return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />;
|
||||
})();
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
@@ -188,23 +185,21 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
|
||||
{/* Grid */}
|
||||
<img
|
||||
tw="absolute inset-0 w-[100vw] h-[100vh]"
|
||||
src={(await fetchStaticImage(gridAsset)).src}
|
||||
src={await readStaticImage(gridAsset)}
|
||||
alt="Grid"
|
||||
/>
|
||||
|
||||
{/* Logo */}
|
||||
{customization.header.logo ? (
|
||||
<div tw="flex flex-row">
|
||||
<img
|
||||
{...(await fetchImage(
|
||||
useLightTheme
|
||||
? customization.header.logo.light
|
||||
: customization.header.logo.dark
|
||||
))}
|
||||
alt="Logo"
|
||||
tw="h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
alt="Logo"
|
||||
height={60}
|
||||
src={
|
||||
useLightTheme
|
||||
? customization.header.logo.light
|
||||
: customization.header.logo.dark
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div tw="flex">
|
||||
{favicon}
|
||||
@@ -292,6 +287,34 @@ 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>();
|
||||
|
||||
@@ -310,32 +333,16 @@ async function getWithCache<T>(key: string, fn: () => Promise<T>) {
|
||||
/**
|
||||
* Read a static image and cache it in memory.
|
||||
*/
|
||||
async function fetchStaticImage(url: string) {
|
||||
return getWithCache(`static-image:${url}`, () => fetchImage(url));
|
||||
async function readStaticImage(url: string) {
|
||||
logOnCloudflareOnly(`Reading static image: ${url}, cache size: ${staticCache.size}`);
|
||||
return getWithCache(`static-image:${url}`, () => readSelfImage(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Read an image from GitBook itself.
|
||||
*/
|
||||
async function fetchImage(url: string) {
|
||||
async function readSelfImage(url: string) {
|
||||
const response = await fetch(url);
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
throw new Error(`Invalid content type: ${contentType}`);
|
||||
}
|
||||
|
||||
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 (error) {
|
||||
console.error(`Error reading image size: ${error}`);
|
||||
return { src };
|
||||
}
|
||||
const image = await readImage(response);
|
||||
return image;
|
||||
}
|
||||
|
||||
@@ -38,18 +38,10 @@ export function ContentKit<RenderContext>(props: {
|
||||
render: (input: {
|
||||
renderContext: RenderContext;
|
||||
request: RequestRenderIntegrationUI;
|
||||
}) => Promise<
|
||||
| {
|
||||
error?: undefined;
|
||||
children: React.ReactNode;
|
||||
output: ContentKitRenderOutput;
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
children?: undefined;
|
||||
output?: undefined;
|
||||
}
|
||||
>;
|
||||
}) => Promise<{
|
||||
children: React.ReactNode;
|
||||
output: ContentKitRenderOutput;
|
||||
}>;
|
||||
/** Callback when an action is triggered */
|
||||
onAction?: (action: ContentKitAction) => void;
|
||||
/** Callback when the flow is completed */
|
||||
@@ -106,18 +98,17 @@ export function ContentKit<RenderContext>(props: {
|
||||
request: newInput,
|
||||
});
|
||||
const output = result.output;
|
||||
if (output) {
|
||||
if (output.type === 'complete') {
|
||||
return onComplete?.(output.returnValue);
|
||||
}
|
||||
|
||||
setCurrent((prev) => ({
|
||||
input: newInput,
|
||||
children: result.children,
|
||||
output: output,
|
||||
state: prev.state,
|
||||
}));
|
||||
if (output.type === 'complete') {
|
||||
return onComplete?.(output.returnValue);
|
||||
}
|
||||
|
||||
setCurrent((prev) => ({
|
||||
input: newInput,
|
||||
children: result.children,
|
||||
output: output,
|
||||
state: prev.state,
|
||||
}));
|
||||
},
|
||||
[setCurrent, current, render, onComplete]
|
||||
);
|
||||
@@ -156,10 +147,8 @@ export function ContentKit<RenderContext>(props: {
|
||||
renderContext,
|
||||
request: modalInput,
|
||||
});
|
||||
if (
|
||||
result.output &&
|
||||
(result.output.type === 'element' || !result.output.type)
|
||||
) {
|
||||
|
||||
if (result.output.type === 'element' || !result.output.type) {
|
||||
setSubView({
|
||||
mode: 'modal',
|
||||
initialInput: modalInput,
|
||||
|
||||
Reference in New Issue
Block a user