diff --git a/.changeset/afraid-gifts-sparkle.md b/.changeset/afraid-gifts-sparkle.md new file mode 100644 index 000000000..ec33c1efc --- /dev/null +++ b/.changeset/afraid-gifts-sparkle.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix UX issue about highlighting the search term in search result sections diff --git a/.changeset/curly-rules-learn.md b/.changeset/curly-rules-learn.md new file mode 100644 index 000000000..47400e919 --- /dev/null +++ b/.changeset/curly-rules-learn.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Add support for inline icons. diff --git a/.changeset/famous-melons-compete.md b/.changeset/famous-melons-compete.md new file mode 100644 index 000000000..17512b6f4 --- /dev/null +++ b/.changeset/famous-melons-compete.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix crash when integration script fails to render block. diff --git a/.changeset/fuzzy-tables-jump.md b/.changeset/fuzzy-tables-jump.md new file mode 100644 index 000000000..ada33d1c5 --- /dev/null +++ b/.changeset/fuzzy-tables-jump.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Optimize performances by using a smarter per-request cache arround data cached functions diff --git a/.changeset/green-clouds-cough.md b/.changeset/green-clouds-cough.md new file mode 100644 index 000000000..098860381 --- /dev/null +++ b/.changeset/green-clouds-cough.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Fix rendering of ogimage with SVG logos. diff --git a/.changeset/khaki-bees-count.md b/.changeset/khaki-bees-count.md new file mode 100644 index 000000000..a481690e2 --- /dev/null +++ b/.changeset/khaki-bees-count.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix crash when integration is triggering invalid requests. diff --git a/.changeset/orange-ears-drop.md b/.changeset/orange-ears-drop.md new file mode 100644 index 000000000..12dd7fae5 --- /dev/null +++ b/.changeset/orange-ears-drop.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-contentkit": patch +--- + +Add basic error handling when transitioning between states. diff --git a/.changeset/pink-windows-wonder.md b/.changeset/pink-windows-wonder.md new file mode 100644 index 000000000..d8e9560dc --- /dev/null +++ b/.changeset/pink-windows-wonder.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Don't crash ogimage generation on RTL text, as a workaround until we can support it. diff --git a/.changeset/pretty-balloons-fold.md b/.changeset/pretty-balloons-fold.md new file mode 100644 index 000000000..b50187984 --- /dev/null +++ b/.changeset/pretty-balloons-fold.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix rendering of ogimage when logo or icon are AVIF images. diff --git a/.changeset/purple-cougars-breathe.md b/.changeset/purple-cougars-breathe.md new file mode 100644 index 000000000..2c6d010d4 --- /dev/null +++ b/.changeset/purple-cougars-breathe.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add margin to adjacent buttons diff --git a/.changeset/rare-pens-whisper.md b/.changeset/rare-pens-whisper.md new file mode 100644 index 000000000..aed0712ba --- /dev/null +++ b/.changeset/rare-pens-whisper.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix ogimage generation failing with some JPEG images. diff --git a/.changeset/real-walls-glow.md b/.changeset/real-walls-glow.md new file mode 100644 index 000000000..277c39ca9 --- /dev/null +++ b/.changeset/real-walls-glow.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Don't cache unexpected API errors for more than a few minutes. diff --git a/.changeset/rich-buses-hunt.md b/.changeset/rich-buses-hunt.md new file mode 100644 index 000000000..fbc59d232 --- /dev/null +++ b/.changeset/rich-buses-hunt.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +Fix an issue where PDF export URLs were not keeping their query params. diff --git a/.changeset/rotten-donuts-bow.md b/.changeset/rotten-donuts-bow.md new file mode 100644 index 000000000..d6ebe0650 --- /dev/null +++ b/.changeset/rotten-donuts-bow.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +add a global error boundary diff --git a/.changeset/slimy-cows-press.md b/.changeset/slimy-cows-press.md new file mode 100644 index 000000000..17ec44ca8 --- /dev/null +++ b/.changeset/slimy-cows-press.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Ignore case while highlighting search results. diff --git a/.changeset/soft-walls-change.md b/.changeset/soft-walls-change.md new file mode 100644 index 000000000..2086de031 --- /dev/null +++ b/.changeset/soft-walls-change.md @@ -0,0 +1,6 @@ +--- +"gitbook": patch +"gitbook-v2": patch +--- + +Fix InlineLinkTooltip having a negative impact on performance, especially on larger pages. diff --git a/.changeset/thick-cups-shout.md b/.changeset/thick-cups-shout.md new file mode 100644 index 000000000..3aabb9ad4 --- /dev/null +++ b/.changeset/thick-cups-shout.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix crash during rendering of ogimage for VA sites with default icon. diff --git a/README.md b/README.md index 8a9f5c2fd..9b13767ff 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/bun.lock b/bun.lock index 3ff6d61c4..8afc0e702 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 1faa750ba..1cf5326c1 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "workspaces": { "packages": ["packages/*"], "catalog": { - "@gitbook/api": "^0.120.0" + "@gitbook/api": "^0.121.0" } }, "patchedDependencies": { diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 5723cdbc7..cfa6e71b9 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -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": "*", diff --git a/packages/gitbook-v2/src/app/global-error.tsx b/packages/gitbook-v2/src/app/global-error.tsx new file mode 100644 index 000000000..abf0186a4 --- /dev/null +++ b/packages/gitbook-v2/src/app/global-error.tsx @@ -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 ( + + + + + + ); +} diff --git a/packages/gitbook-v2/src/lib/cache.test.ts b/packages/gitbook-v2/src/lib/cache.test.ts new file mode 100644 index 000000000..735b1fb06 --- /dev/null +++ b/packages/gitbook-v2/src/lib/cache.test.ts @@ -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); + }); +}); diff --git a/packages/gitbook-v2/src/lib/cache.ts b/packages/gitbook-v2/src/lib/cache.ts new file mode 100644 index 000000000..e05753907 --- /dev/null +++ b/packages/gitbook-v2/src/lib/cache.ts @@ -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(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(): (value: T) => T { + const reverseIndex = new WeakMap(); + const refIndex = new Map(); + + return (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'; +} diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index 88c95c401..8e6535331 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -8,8 +8,8 @@ import { import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags'; import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env'; import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; -import { DataFetcherError, wrapDataFetcherError } from './errors'; -import { withCacheKey, withoutConcurrentExecution } from './memoize'; +import { cache } from '../cache'; +import { DataFetcherError, wrapCacheDataFetcherError } from './errors'; import type { GitBookDataFetcher } from './types'; interface DataFetcherInput { @@ -204,307 +204,152 @@ export function createDataFetcher( }; } -const getUserById = withCacheKey( - withoutConcurrentExecution(async (_, input: DataFetcherInput, params: { userId: string }) => { - 'use cache'; +const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { return trace(`getUserById(${params.userId})`, async () => { - return wrapDataFetcherError(async () => { + const api = apiClient(input); + const res = await api.users.getUserById(params.userId, { + ...noCacheFetchOptions, + }); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); + return res.data; + }); + }); +}); + +const getSpace = cache( + async (input: DataFetcherInput, params: { spaceId: string; shareKey: string | undefined }) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'space', + space: params.spaceId, + }) + ); + + return wrapCacheDataFetcherError(async () => { + return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => { const api = apiClient(input); - const res = await api.users.getUserById(params.userId, { - ...noCacheFetchOptions, - }); + const res = await api.spaces.getSpaceById( + params.spaceId, + { + shareKey: params.shareKey, + }, + { + ...noCacheFetchOptions, + } + ); cacheTag(...getCacheTagsFromResponse(res)); cacheLife('days'); return res.data; }); }); - }) + } ); -const getSpace = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; shareKey: string | undefined } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'space', - space: params.spaceId, - }) - ); - - return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getSpaceById( - params.spaceId, - { - shareKey: params.shareKey, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - return res.data; - }); - }); - } - ) -); - -const getChangeRequest = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; changeRequestId: string } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'change-request', - space: params.spaceId, - changeRequest: params.changeRequestId, - }) - ); +const getChangeRequest = cache( + async (input: DataFetcherInput, params: { spaceId: string; changeRequestId: string }) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'change-request', + space: params.spaceId, + changeRequest: params.changeRequestId, + }) + ); + return wrapCacheDataFetcherError(async () => { return trace( `getChangeRequest(${params.spaceId}, ${params.changeRequestId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getChangeRequestById( - params.spaceId, - params.changeRequestId, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('minutes'); - return res.data; - }); + const api = apiClient(input); + const res = await api.spaces.getChangeRequestById( + params.spaceId, + params.changeRequestId, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('minutes'); + return res.data; } ); - } - ) + }); + } ); -const getRevision = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } - ) => { - 'use cache'; +const getRevision = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getRevisionById( - params.spaceId, - params.revisionId, - { - metadata: params.metadata, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); + const api = apiClient(input); + const res = await api.spaces.getRevisionById( + params.spaceId, + params.revisionId, + { + metadata: params.metadata, + }, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; }); - } - ) + }); + } ); -const getRevisionPages = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } - ) => { - 'use cache'; +const getRevisionPages = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { return trace(`getRevisionPages(${params.spaceId}, ${params.revisionId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.listPagesInRevisionById( - params.spaceId, - params.revisionId, - { - metadata: params.metadata, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data.pages; - }); + const api = apiClient(input); + const res = await api.spaces.listPagesInRevisionById( + params.spaceId, + params.revisionId, + { + metadata: params.metadata, + }, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data.pages; }); - } - ) + }); + } ); -const getRevisionFile = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; fileId: string } - ) => { - 'use cache'; +const getRevisionFile = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; fileId: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { return trace( `getRevisionFile(${params.spaceId}, ${params.revisionId}, ${params.fileId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getFileInRevisionById( - params.spaceId, - params.revisionId, - params.fileId, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); - } - ); - } - ) -); - -const getRevisionPageMarkdown = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; pageId: string } - ) => { - 'use cache'; - return trace( - `getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getPageInRevisionById( - params.spaceId, - params.revisionId, - params.pageId, - { - format: 'markdown', - }, - { - ...noCacheFetchOptions, - } - ); - - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - - if (!('markdown' in res.data)) { - throw new DataFetcherError('Page is not a document', 404); - } - return res.data.markdown; - }); - } - ); - } - ) -); - -const getRevisionPageDocument = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; pageId: string } - ) => { - 'use cache'; - return trace( - `getRevisionPageDocument(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getPageDocumentInRevisionById( - params.spaceId, - params.revisionId, - params.pageId, - { - evaluated: true, - }, - { - ...noCacheFetchOptions, - } - ); - - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - - return res.data; - }); - } - ); - } - ) -); - -const getRevisionPageByPath = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; path: string } - ) => { - 'use cache'; - return trace( - `getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`, - async () => { - const encodedPath = encodeURIComponent(params.path); - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getPageInRevisionByPath( - params.spaceId, - params.revisionId, - encodedPath, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); - } - ); - } - ) -); - -const getDocument = withCacheKey( - withoutConcurrentExecution( - async (_, input: DataFetcherInput, params: { spaceId: string; documentId: string }) => { - 'use cache'; - return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => { - return wrapDataFetcherError(async () => { const api = apiClient(input); - const res = await api.spaces.getDocumentById( + const res = await api.spaces.getFileInRevisionById( params.spaceId, - params.documentId, + params.revisionId, + params.fileId, {}, { ...noCacheFetchOptions, @@ -513,309 +358,264 @@ const getDocument = withCacheKey( cacheTag(...getCacheTagsFromResponse(res)); cacheLife('max'); return res.data; - }); - }); - } - ) -); - -const getComputedDocument = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { - spaceId: string; - organizationId: string; - source: ComputedContentSource; - seed: string; - } - ) => { - 'use cache'; - cacheTag( - ...getComputedContentSourceCacheTags( - { - spaceId: params.spaceId, - organizationId: params.organizationId, - }, - params.source - ) - ); - - return trace( - `getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getComputedDocument( - params.spaceId, - { - source: params.source, - seed: params.seed, - }, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); } ); - } - ) + }); + } ); -const getReusableContent = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; reusableContentId: string } - ) => { - 'use cache'; +const getRevisionPageMarkdown = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; pageId: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { return trace( - `getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, + `getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.spaces.getReusableContentInRevisionById( - params.spaceId, - params.revisionId, - params.reusableContentId, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); - } - ); - } - ) -); - -const getLatestOpenAPISpecVersionContent = withCacheKey( - withoutConcurrentExecution( - async (_, input: DataFetcherInput, params: { organizationId: string; slug: string }) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'openapi', - organization: params.organizationId, - openAPISpec: params.slug, - }) - ); - - return trace( - `getLatestOpenAPISpecVersionContent(${params.organizationId}, ${params.slug})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getLatestOpenApiSpecVersionContent( - params.organizationId, - params.slug, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('max'); - return res.data; - }); - } - ); - } - ) -); - -const getPublishedContentSite = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { organizationId: string; siteId: string; siteShareKey: string | undefined } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'site', - site: params.siteId, - }) - ); - - return trace( - `getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getPublishedContentSite( - params.organizationId, - params.siteId, - { - shareKey: params.siteShareKey, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - return res.data; - }); - } - ); - } - ) -); - -const getSiteRedirectBySource = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { - organizationId: string; - siteId: string; - siteShareKey: string | undefined; - source: string; - } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'site', - site: params.siteId, - }) - ); - - return trace( - `getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`, - async () => { - return wrapDataFetcherError(async () => { - const api = apiClient(input); - const res = await api.orgs.getSiteRedirectBySource( - params.organizationId, - params.siteId, - { - shareKey: params.siteShareKey, - source: params.source, - }, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('days'); - return res.data; - }); - } - ); - } - ) -); - -const getEmbedByUrl = withCacheKey( - withoutConcurrentExecution( - async (_, input: DataFetcherInput, params: { spaceId: string; url: string }) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'space', - space: params.spaceId, - }) - ); - - return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => { - return wrapDataFetcherError(async () => { const api = apiClient(input); - const res = await api.spaces.getEmbedByUrlInSpace( + const res = await api.spaces.getPageInRevisionById( params.spaceId, + params.revisionId, + params.pageId, { - url: params.url, + format: 'markdown', }, { ...noCacheFetchOptions, } ); + cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('weeks'); - return res.data; - }); - }); - } - ) -); + cacheLife('max'); -const searchSiteContent = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: Parameters[0] - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'site', - site: params.siteId, - }) - ); - - return trace( - `searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`, - async () => { - return wrapDataFetcherError(async () => { - const { organizationId, siteId, query, scope } = params; - const api = apiClient(input); - const res = await api.orgs.searchSiteContent( - organizationId, - siteId, - { - query, - ...scope, - }, - {}, - { - ...noCacheFetchOptions, - } - ); - cacheTag(...getCacheTagsFromResponse(res)); - cacheLife('hours'); - return res.data.items; - }); + if (!('markdown' in res.data)) { + throw new DataFetcherError('Page is not a document', 404); + } + return res.data.markdown; } ); - } - ) + }); + } ); -const renderIntegrationUi = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { integrationName: string; request: RenderIntegrationUI } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'integration', - integration: params.integrationName, - }) - ); - - return trace(`renderIntegrationUi(${params.integrationName})`, async () => { - return wrapDataFetcherError(async () => { +const getRevisionPageDocument = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; pageId: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace( + `getRevisionPageDocument(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, + async () => { const api = apiClient(input); - const res = await api.integrations.renderIntegrationUiWithPost( - params.integrationName, - params.request, + const res = await api.spaces.getPageDocumentInRevisionById( + params.spaceId, + params.revisionId, + params.pageId, + { + evaluated: true, + }, + { + ...noCacheFetchOptions, + } + ); + + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + + return res.data; + } + ); + }); + } +); + +const getRevisionPageByPath = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; path: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace( + `getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`, + async () => { + const encodedPath = encodeURIComponent(params.path); + const api = apiClient(input); + const res = await api.spaces.getPageInRevisionByPath( + params.spaceId, + params.revisionId, + encodedPath, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + } + ); + }); + } +); + +const getDocument = cache( + async (input: DataFetcherInput, params: { spaceId: string; documentId: string }) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => { + const api = apiClient(input); + const res = await api.spaces.getDocumentById( + params.spaceId, + params.documentId, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + }); + }); + } +); + +const getComputedDocument = cache( + async ( + input: DataFetcherInput, + params: { + spaceId: string; + organizationId: string; + source: ComputedContentSource; + seed: string; + } + ) => { + 'use cache'; + cacheTag( + ...getComputedContentSourceCacheTags( + { + spaceId: params.spaceId, + organizationId: params.organizationId, + }, + params.source + ) + ); + + return wrapCacheDataFetcherError(async () => { + return trace( + `getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getComputedDocument( + params.spaceId, + { + source: params.source, + seed: params.seed, + }, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + } + ); + }); + } +); + +const getReusableContent = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; reusableContentId: string } + ) => { + 'use cache'; + return wrapCacheDataFetcherError(async () => { + return trace( + `getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getReusableContentInRevisionById( + params.spaceId, + params.revisionId, + params.reusableContentId, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + } + ); + }); + } +); + +const getLatestOpenAPISpecVersionContent = cache( + async (input: DataFetcherInput, params: { organizationId: string; slug: string }) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'openapi', + organization: params.organizationId, + openAPISpec: params.slug, + }) + ); + + return wrapCacheDataFetcherError(async () => { + return trace( + `getLatestOpenAPISpecVersionContent(${params.organizationId}, ${params.slug})`, + async () => { + const api = apiClient(input); + const res = await api.orgs.getLatestOpenApiSpecVersionContent( + params.organizationId, + params.slug, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + } + ); + }); + } +); + +const getPublishedContentSite = cache( + async ( + input: DataFetcherInput, + params: { organizationId: string; siteId: string; siteShareKey: string | undefined } + ) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); + + return wrapCacheDataFetcherError(async () => { + return trace( + `getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`, + async () => { + const api = apiClient(input); + const res = await api.orgs.getPublishedContentSite( + params.organizationId, + params.siteId, + { + shareKey: params.siteShareKey, + }, { ...noCacheFetchOptions, } @@ -823,10 +623,154 @@ const renderIntegrationUi = withCacheKey( cacheTag(...getCacheTagsFromResponse(res)); cacheLife('days'); return res.data; - }); - }); + } + ); + }); + } +); + +const getSiteRedirectBySource = cache( + async ( + input: DataFetcherInput, + params: { + organizationId: string; + siteId: string; + siteShareKey: string | undefined; + source: string; } - ) + ) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); + + return wrapCacheDataFetcherError(async () => { + return trace( + `getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`, + async () => { + const api = apiClient(input); + const res = await api.orgs.getSiteRedirectBySource( + params.organizationId, + params.siteId, + { + shareKey: params.siteShareKey, + source: params.source, + }, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); + return res.data; + } + ); + }); + } +); + +const getEmbedByUrl = cache( + async (input: DataFetcherInput, params: { spaceId: string; url: string }) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'space', + space: params.spaceId, + }) + ); + + return wrapCacheDataFetcherError(async () => { + return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => { + const api = apiClient(input); + const res = await api.spaces.getEmbedByUrlInSpace( + params.spaceId, + { + url: params.url, + }, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('weeks'); + return res.data; + }); + }); + } +); + +const searchSiteContent = cache( + async ( + input: DataFetcherInput, + params: Parameters[0] + ) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'site', + site: params.siteId, + }) + ); + + return wrapCacheDataFetcherError(async () => { + return trace( + `searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`, + async () => { + const { organizationId, siteId, query, scope } = params; + const api = apiClient(input); + const res = await api.orgs.searchSiteContent( + organizationId, + siteId, + { + query, + ...scope, + }, + {}, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('hours'); + return res.data.items; + } + ); + }); + } +); + +const renderIntegrationUi = cache( + async ( + input: DataFetcherInput, + params: { integrationName: string; request: RenderIntegrationUI } + ) => { + 'use cache'; + cacheTag( + getCacheTag({ + tag: 'integration', + integration: params.integrationName, + }) + ); + + return wrapCacheDataFetcherError(async () => { + return trace(`renderIntegrationUi(${params.integrationName})`, async () => { + const api = apiClient(input); + const res = await api.integrations.renderIntegrationUiWithPost( + params.integrationName, + params.request, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('days'); + return res.data; + }); + }); + } ); async function* streamAIResponse( diff --git a/packages/gitbook-v2/src/lib/data/errors.ts b/packages/gitbook-v2/src/lib/data/errors.ts index 784eebdbb..c4d983ec2 100644 --- a/packages/gitbook-v2/src/lib/data/errors.ts +++ b/packages/gitbook-v2/src/lib/data/errors.ts @@ -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( 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( } } +/** + * Wrap an async execution to handle errors and return a DataFetcherResponse. + * This should be used inside 'use cache' functions. + */ +export async function wrapCacheDataFetcherError( + fn: () => Promise +): Promise> { + 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( + response: DataFetcherResponse, + ignoreErrors?: number[] +): DataFetcherResponse; +export function ignoreDataFetcherErrors( + response: Promise>, + ignoreErrors?: number[] +): Promise>; +export function ignoreDataFetcherErrors( + response: DataFetcherResponse | Promise>, + ignoreErrors: number[] = [404] +): DataFetcherResponse | Promise> { + 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. */ diff --git a/packages/gitbook-v2/src/lib/data/lookup.ts b/packages/gitbook-v2/src/lib/data/lookup.ts index 4c999bd7a..be8c21f1d 100644 --- a/packages/gitbook-v2/src/lib/data/lookup.ts +++ b/packages/gitbook-v2/src/lib/data/lookup.ts @@ -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 = ReturnType>; - -async function lookupPublishedContentByUrl(input: { - url: string; - fetchLookupAPIResult: (args: { - url: string; - signal: AbortSignal; - }) => TryCatch>>; -}): Promise> { +export async function lookupPublishedContentByUrl( + input: LookupPublishedContentByUrlInput +): Promise> { 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) { diff --git a/packages/gitbook-v2/src/lib/data/memoize.ts b/packages/gitbook-v2/src/lib/data/memoize.ts deleted file mode 100644 index 5110d015f..000000000 --- a/packages/gitbook-v2/src/lib/data/memoize.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { cache } from 'react'; - -// This is used to create a context specific to the current request. -// This version works both in cloudflare and in vercel. -const getRequestContext = cache(() => ({})); - -/** - * Wrap a function by preventing concurrent executions of the same function. - * With a logic to work per-request in Cloudflare Workers. - */ -export function withoutConcurrentExecution( - wrapped: (key: string, ...args: ArgsType) => Promise -): (cacheKey: string, ...args: ArgsType) => Promise { - const globalPromiseCache = new WeakMap>>(); - - return (key: string, ...args: ArgsType) => { - const globalContext = getRequestContext(); - - /** - * 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>(); - 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( - wrapped: (cacheKey: string, ...args: ArgsType) => Promise -): (...args: ArgsType) => Promise { - 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; -} diff --git a/packages/gitbook-v2/src/lib/data/pages.ts b/packages/gitbook-v2/src/lib/data/pages.ts index b9e3cabc5..319c0a917 100644 --- a/packages/gitbook-v2/src/lib/data/pages.ts +++ b/packages/gitbook-v2/src/lib/data/pages.ts @@ -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 { 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,33 +38,5 @@ 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, - }) - ) - ); - } - } - 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; -} diff --git a/packages/gitbook-v2/src/lib/images/resizer/types.ts b/packages/gitbook-v2/src/lib/images/resizer/types.ts index 8bbe697f9..9cb370586 100644 --- a/packages/gitbook-v2/src/lib/images/resizer/types.ts +++ b/packages/gitbook-v2/src/lib/images/resizer/types.ts @@ -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; diff --git a/packages/gitbook-v2/src/middleware.ts b/packages/gitbook-v2/src/middleware.ts index 0d57ce27e..4bcbf77ae 100644 --- a/packages/gitbook-v2/src/middleware.ts +++ b/packages/gitbook-v2/src/middleware.ts @@ -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,18 +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', - 'paypal.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); @@ -104,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, @@ -275,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, diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 3efe00bd2..3a884ae5e 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -603,6 +603,11 @@ const testCases: TestsCase[] = [ url: 'blocks/emojis', run: waitForCookiesDialog, }, + { + name: 'Icons', + url: 'blocks/icons', + run: waitForCookiesDialog, + }, { name: 'Links', url: 'blocks/links', diff --git a/packages/gitbook/e2e/pdf.spec.ts b/packages/gitbook/e2e/pdf.spec.ts new file mode 100644 index 000000000..4798a2a04 --- /dev/null +++ b/packages/gitbook/e2e/pdf.spec.ts @@ -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); + }, + }); + }); +}); diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index f54b70fbe..937b776d7 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -346,7 +346,7 @@ export function getCustomizationURL(partial: DeepPartial { const urlStates: Record< string, diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 379f975a1..a430218cf 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -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", diff --git a/packages/gitbook/src/components/Ads/AdClassicRendering.tsx b/packages/gitbook/src/components/Ads/AdClassicRendering.tsx index e6d263bf6..82613af49 100644 --- a/packages/gitbook/src/components/Ads/AdClassicRendering.tsx +++ b/packages/gitbook/src/components/Ads/AdClassicRendering.tsx @@ -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 ( 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; + } } } } diff --git a/packages/gitbook/src/components/DocumentView/DocumentView.tsx b/packages/gitbook/src/components/DocumentView/DocumentView.tsx index 724a7f2b0..1ff8542ee 100644 --- a/packages/gitbook/src/components/DocumentView/DocumentView.tsx +++ b/packages/gitbook/src/components/DocumentView/DocumentView.tsx @@ -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 { diff --git a/packages/gitbook/src/components/DocumentView/Inline.tsx b/packages/gitbook/src/components/DocumentView/Inline.tsx index 1e1b091ce..84b56decf 100644 --- a/packages/gitbook/src/components/DocumentView/Inline.tsx +++ b/packages/gitbook/src/components/DocumentView/Inline.tsx @@ -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(props: InlineProps) { return ; case 'button': return ; + case 'icon': + return ; case 'expression': // The GitBook API should take care of evaluating expressions. // We should never need to render them. diff --git a/packages/gitbook/src/components/DocumentView/InlineButton.tsx b/packages/gitbook/src/components/DocumentView/InlineButton.tsx index 9841c0262..a36cd7452 100644 --- a/packages/gitbook/src/components/DocumentView/InlineButton.tsx +++ b/packages/gitbook/src/components/DocumentView/InlineButton.tsx @@ -17,18 +17,22 @@ export async function InlineButton(props: InlineProps) } return ( -