From 2863fe0dc17e2bf20dc8cb4137f72480024cc73a Mon Sep 17 00:00:00 2001 From: Taran Vohra Date: Mon, 16 Jun 2025 09:59:55 +0530 Subject: [PATCH 1/4] Use `resolvePublishedContentByUrl` instead of the deprecated resolution endpoint (#3310) --- packages/gitbook-v2/src/lib/data/lookup.ts | 97 +++++----------------- packages/gitbook-v2/src/middleware.ts | 20 +---- 2 files changed, 24 insertions(+), 93 deletions(-) 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/middleware.ts b/packages/gitbook-v2/src/middleware.ts index 0d57ce27e..d413d6a43 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, From 42d88da73ce7d0a71e20c60625571bc773443c56 Mon Sep 17 00:00:00 2001 From: Utku Ufuk Date: Mon, 16 Jun 2025 12:31:15 +0200 Subject: [PATCH 2/4] Fix UX issue about highlighting the search term in search result sections (#3323) --- .changeset/afraid-gifts-sparkle.md | 5 +++++ README.md | 10 ++++++++-- .../Search/SearchSectionResultItem.tsx | 17 ++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 .changeset/afraid-gifts-sparkle.md 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/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/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx index 960b1eb38..a0f467f80 100644 --- a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx @@ -66,11 +66,7 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe

) : null} - {item.body ? ( -

- -

- ) : null} + {item.body ? highlightQueryInBody(item.body, query) : null}
); }); + +function highlightQueryInBody(body: string, query: string) { + const idx = body.indexOf(query); + + // Ensure the query to be highlighted is visible in the body. + return ( +

+ +

+ ); +} From 72cd0e59e683ace3d269f0e59338b41c36b88651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Mon, 16 Jun 2025 13:37:45 +0200 Subject: [PATCH 3/4] Replace withoutConcurrency by a smarter React.cache (#3325) --- .changeset/fuzzy-tables-jump.md | 5 + bun.lock | 3 + packages/gitbook-v2/package.json | 3 +- packages/gitbook-v2/src/lib/cache.test.ts | 80 ++ packages/gitbook-v2/src/lib/cache.ts | 59 + packages/gitbook-v2/src/lib/data/api.ts | 1077 +++++++++---------- packages/gitbook-v2/src/lib/data/memoize.ts | 92 -- 7 files changed, 658 insertions(+), 661 deletions(-) create mode 100644 .changeset/fuzzy-tables-jump.md create mode 100644 packages/gitbook-v2/src/lib/cache.test.ts create mode 100644 packages/gitbook-v2/src/lib/cache.ts delete mode 100644 packages/gitbook-v2/src/lib/data/memoize.ts 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/bun.lock b/bun.lock index 3ff6d61c4..b4d2b49d1 100644 --- a/bun.lock +++ b/bun.lock @@ -169,6 +169,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", @@ -2438,6 +2439,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/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/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..d36c2445f 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 { cache } from '../cache'; import { DataFetcherError, wrapDataFetcherError } from './errors'; -import { withCacheKey, withoutConcurrentExecution } from './memoize'; import type { GitBookDataFetcher } from './types'; interface DataFetcherInput { @@ -204,307 +204,149 @@ export function createDataFetcher( }; } -const getUserById = withCacheKey( - withoutConcurrentExecution(async (_, input: DataFetcherInput, params: { userId: string }) => { +const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => { + 'use cache'; + 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'; - return trace(`getUserById(${params.userId})`, async () => { + 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.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, - }) - ); +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 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; - }); + 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 getChangeRequest = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; changeRequestId: string } - ) => { - 'use cache'; - cacheTag( - getCacheTag({ - tag: 'change-request', - space: params.spaceId, - changeRequest: params.changeRequestId, - }) - ); - - 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 getRevision = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } + ) => { + 'use cache'; + 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 getRevision = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; metadata: boolean } - ) => { - 'use cache'; - return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => { +const getRevisionPages = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; metadata: boolean } + ) => { + 'use cache'; + 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 getRevisionFile = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; fileId: string } + ) => { + 'use cache'; + return trace( + `getRevisionFile(${params.spaceId}, ${params.revisionId}, ${params.fileId})`, + async () => { return wrapDataFetcherError(async () => { const api = apiClient(input); - const res = await api.spaces.getRevisionById( + const res = await api.spaces.getFileInRevisionById( 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'; - 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 getRevisionFile = withCacheKey( - withoutConcurrentExecution( - async ( - _, - input: DataFetcherInput, - params: { spaceId: string; revisionId: string; fileId: string } - ) => { - 'use cache'; - 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( - params.spaceId, - params.documentId, + params.fileId, {}, { ...noCacheFetchOptions, @@ -514,308 +356,263 @@ const getDocument = withCacheKey( 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'; - return trace( - `getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, - 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 () => { +const getRevisionPageMarkdown = cache( + 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.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'); + + if (!('markdown' in res.data)) { + throw new DataFetcherError('Page is not a document', 404); + } + return res.data.markdown; }); - }); - } - ) + } + ); + } ); -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; - }); - } - ); - } - ) -); - -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 () => { +const getRevisionPageDocument = cache( + 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.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 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 = cache( + 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( + 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 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 = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; reusableContentId: string } + ) => { + 'use cache'; + return trace( + `getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, + 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 = cache( + 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 = cache( + 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, } @@ -824,9 +621,153 @@ const renderIntegrationUi = withCacheKey( 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 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 = cache( + 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( + 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 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; + }); + } + ); + } +); + +const renderIntegrationUi = cache( + 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 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/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; -} From d99da6a3ae7f128116bbec9ade8dcbe01f94eb31 Mon Sep 17 00:00:00 2001 From: Utku Ufuk Date: Mon, 16 Jun 2025 14:33:00 +0200 Subject: [PATCH 4/4] Ignore case while pattern-matching to highlight search results (#3326) --- .changeset/slimy-cows-press.md | 5 +++++ .../src/components/Search/SearchSectionResultItem.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/slimy-cows-press.md 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/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx index a0f467f80..8ccda684d 100644 --- a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx @@ -88,12 +88,12 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe }); function highlightQueryInBody(body: string, query: string) { - const idx = body.indexOf(query); + const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()); // Ensure the query to be highlighted is visible in the body. return (

- +

); }