diff --git a/.changeset/polite-falcons-agree.md b/.changeset/polite-falcons-agree.md new file mode 100644 index 0000000000..7743ab7095 --- /dev/null +++ b/.changeset/polite-falcons-agree.md @@ -0,0 +1,6 @@ +--- +"gitbook-v2": minor +"gitbook": minor +--- + +Add support for reusable content across spaces. diff --git a/bun.lock b/bun.lock index 5476639847..ff44480802 100644 --- a/bun.lock +++ b/bun.lock @@ -259,7 +259,7 @@ }, "overrides": { "@codemirror/state": "6.4.1", - "@gitbook/api": "0.111.0", + "@gitbook/api": "0.113.0", "react": "^19.0.0", "react-dom": "^19.0.0", }, @@ -624,7 +624,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.111.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-E5Pk28kPD4p6XNWdwFM9pgDijdByseIZQqcFK+/hoW5tEZa5Yw/plRKJyN1hmwfPL6SKq6Maf0fbIzTQiVXyQQ=="], + "@gitbook/api": ["@gitbook/api@0.113.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-PWMeAkdm4bHSl3b5OmtcmskZ6qRkkDhauCPybo8sGnjS03O14YAUtubAQiNCKX/uwbs+yiQ8KRPyeIwn+g42yw=="], "@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"], diff --git a/package.json b/package.json index fba8d79576..00f323e2f4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "packageManager": "bun@1.2.8", "overrides": { "@codemirror/state": "6.4.1", - "@gitbook/api": "0.111.0", + "@gitbook/api": "0.113.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx b/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx index fb692c6824..155077ca25 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/contentkit.tsx @@ -20,6 +20,8 @@ export const contentKitServerContext: ContentKitServerContext = { 'link-external': (props) => , eye: (props) => , lock: (props) => , + check: (props) => , + 'check-circle': (props) => , }, codeBlock: (props) => { return ; diff --git a/packages/gitbook/src/components/DocumentView/ReusableContent.tsx b/packages/gitbook/src/components/DocumentView/ReusableContent.tsx index e95ffc7545..7e2d28d8d2 100644 --- a/packages/gitbook/src/components/DocumentView/ReusableContent.tsx +++ b/packages/gitbook/src/components/DocumentView/ReusableContent.tsx @@ -13,15 +13,28 @@ export async function ReusableContent(props: BlockProps face.weight === 400 || face.weight === 700 diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index 2bb2039bc3..ca3ce3b1f8 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -267,6 +267,7 @@ export const getPublishedContentByUrl = cache({ const parsed = parseCacheResponse(response); + // biome-ignore lint/suspicious/noConsole: log the ttl of the token console.log( `Parsed ttl: ${parsed.ttl} at ${Date.now()}, for ${'apiToken' in response.data ? response.data.apiToken : ''}` ); diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index ab941e43c5..2ef8c48a6a 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -41,8 +41,12 @@ export interface ResolvedContentRef { file?: RevisionFile; /** Page document resolved from the content ref */ page?: RevisionPageDocument; - /** Resolved reusable content, if the ref points to reusable content on a revision. */ - reusableContent?: RevisionReusableContent; + /** Resolved reusable content, if the ref points to reusable content on a revision. Also contains the space and revision used for resolution. */ + reusableContent?: { + revisionReusableContent: RevisionReusableContent; + space: string; + revision: string; + }; /** Resolve OpenAPI spec filesystem. */ openAPIFilesystem?: Filesystem; } @@ -231,21 +235,52 @@ export async function resolveContentRef( } case 'reusable-content': { + // Figure out which space and revision the reusable content is in. + const container: { space: string; revision: string } | null = await (async () => { + // without a space on the content ref, or if the space is the same as the current one, we can use the current revision. + if (!contentRef.space || contentRef.space === context.space.id) { + return { space: context.space.id, revision: revisionId }; + } + + const space = await getDataOrNull( + dataFetcher.getSpace({ + spaceId: contentRef.space, + shareKey: undefined, + }) + ); + + if (!space) { + return null; + } + + return { space: space.id, revision: space.revision }; + })(); + + if (!container) { + return null; + } + const reusableContent = await getDataOrNull( dataFetcher.getReusableContent({ - spaceId: space.id, - revisionId, + spaceId: container.space, + revisionId: container.revision, reusableContentId: contentRef.reusableContent, }) ); + if (!reusableContent) { return null; } + return { - href: getGitBookAppHref(`/s/${space.id}`), + href: getGitBookAppHref(`/s/${container.space}/~/reusable/${reusableContent.id}`), text: reusableContent.title, active: false, - reusableContent, + reusableContent: { + revisionReusableContent: reusableContent, + space: container.space, + revision: container.revision, + }, }; } diff --git a/packages/gitbook/src/lib/v1.ts b/packages/gitbook/src/lib/v1.ts index ea5219816b..eb2accdc24 100644 --- a/packages/gitbook/src/lib/v1.ts +++ b/packages/gitbook/src/lib/v1.ts @@ -8,6 +8,7 @@ import type { GitBookDataFetcher } from '@v2/lib/data/types'; import { createImageResizer } from '@v2/lib/images'; import { createLinker } from '@v2/lib/links'; +import { GitBookAPI } from '@gitbook/api'; import { DataFetcherError, wrapDataFetcherError } from '@v2/lib/data'; import { headers } from 'next/headers'; import { @@ -30,6 +31,7 @@ import { getUserById, renderIntegrationUi, searchSiteContent, + withAPI as withAPIV1, } from './api'; import { getDynamicCustomizationSettings } from './customization'; import { withLeadingSlash, withTrailingSlash } from './paths'; @@ -58,7 +60,7 @@ export async function getV1BaseContext(): Promise { return url; }; - const dataFetcher = await getDataFetcherV1(); + const dataFetcher = getDataFetcherV1(); const imageResizer = createImageResizer({ imagesContextId: host, @@ -82,77 +84,121 @@ export async function getV1BaseContext(): Promise { * Try not to use this as much as possible, and instead take the data fetcher from the props. * This data fetcher should only be used at the top of the tree. */ -async function getDataFetcherV1(): Promise { +function getDataFetcherV1(apiTokenOverride?: string): GitBookDataFetcher { + let apiClient: GitBookAPI | undefined; + + /** + * Run a function with the correct API client. If an API token is provided, we + * create a new API client with the token. Otherwise, we use the default API client. + */ + async function withAPI(fn: () => Promise): Promise { + // No token override - we can use the default API client. + if (!apiTokenOverride) { + return fn(); + } + + const client = await api(); + + if (!apiClient) { + // New client uses same endpoint and user agent as the default client. + apiClient = new GitBookAPI({ + endpoint: client.client.endpoint, + authToken: apiTokenOverride, + userAgent: client.client.userAgent, + }); + } + + return withAPIV1( + { + client: apiClient, + contextId: client.contextId, + }, + fn + ); + } + const dataFetcher: GitBookDataFetcher = { async api() { - const result = await api(); - return result.client; + return withAPI(async () => { + const result = await api(); + return result.client; + }); }, - withToken() { - // In v1, the token is global and controlled by the middleware. - // We don't need to do anything special here. - return dataFetcher; + withToken({ apiToken }) { + return getDataFetcherV1(apiToken); }, getUserById(userId) { - return wrapDataFetcherError(async () => { - const user = await getUserById(userId); - if (!user) { - throw new DataFetcherError('User not found', 404); - } - - return user; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const user = await getUserById(userId); + if (!user) { + throw new DataFetcherError('User not found', 404); + } + + return user; + }) + ); }, getPublishedContentSite(params) { - return wrapDataFetcherError(async () => { - return getPublishedContentSite(params); - }); + return withAPI(() => + wrapDataFetcherError(async () => { + return getPublishedContentSite(params); + }) + ); }, getSpace(params) { - return wrapDataFetcherError(async () => { - return getSpace(params.spaceId, params.shareKey); - }); + return withAPI(() => + wrapDataFetcherError(async () => { + return getSpace(params.spaceId, params.shareKey); + }) + ); }, getChangeRequest(params) { - return wrapDataFetcherError(async () => { - const changeRequest = await getChangeRequest( - params.spaceId, - params.changeRequestId - ); - if (!changeRequest) { - throw new DataFetcherError('Change request not found', 404); - } - - return changeRequest; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const changeRequest = await getChangeRequest( + params.spaceId, + params.changeRequestId + ); + if (!changeRequest) { + throw new DataFetcherError('Change request not found', 404); + } + + return changeRequest; + }) + ); }, getRevision(params) { - return wrapDataFetcherError(async () => { - return getRevision(params.spaceId, params.revisionId, { - metadata: params.metadata, - }); - }); + return withAPI(() => + wrapDataFetcherError(async () => { + return getRevision(params.spaceId, params.revisionId, { + metadata: params.metadata, + }); + }) + ); }, getRevisionFile(params) { - return wrapDataFetcherError(async () => { - const revisionFile = await getRevisionFile( - params.spaceId, - params.revisionId, - params.fileId - ); - if (!revisionFile) { - throw new DataFetcherError('Revision file not found', 404); - } - - return revisionFile; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const revisionFile = await getRevisionFile( + params.spaceId, + params.revisionId, + params.fileId + ); + if (!revisionFile) { + throw new DataFetcherError('Revision file not found', 404); + } + + return revisionFile; + }) + ); }, getRevisionPageMarkdown() { @@ -160,117 +206,140 @@ async function getDataFetcherV1(): Promise { }, getDocument(params) { - return wrapDataFetcherError(async () => { - const document = await getDocument(params.spaceId, params.documentId); - if (!document) { - throw new DataFetcherError('Document not found', 404); - } - - return document; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const document = await getDocument(params.spaceId, params.documentId); + if (!document) { + throw new DataFetcherError('Document not found', 404); + } + + return document; + }) + ); }, getComputedDocument(params) { - return wrapDataFetcherError(() => { - return getComputedDocument( - params.organizationId, - params.spaceId, - params.source, - params.seed - ); - }); + return withAPI(() => + wrapDataFetcherError(() => { + return getComputedDocument( + params.organizationId, + params.spaceId, + params.source, + params.seed + ); + }) + ); }, getRevisionPages(params) { - return wrapDataFetcherError(async () => { - return getRevisionPages(params.spaceId, params.revisionId, { - metadata: params.metadata, - }); - }); + return withAPI(() => + wrapDataFetcherError(async () => { + return getRevisionPages(params.spaceId, params.revisionId, { + metadata: params.metadata, + }); + }) + ); }, getRevisionPageByPath(params) { - return wrapDataFetcherError(async () => { - const revisionPage = await getRevisionPageByPath( - params.spaceId, - params.revisionId, - params.path - ); - - if (!revisionPage) { - throw new DataFetcherError('Revision page not found', 404); - } - - return revisionPage; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const revisionPage = await getRevisionPageByPath( + params.spaceId, + params.revisionId, + params.path + ); + + if (!revisionPage) { + throw new DataFetcherError('Revision page not found', 404); + } + + return revisionPage; + }) + ); }, getReusableContent(params) { - return wrapDataFetcherError(async () => { - const reusableContent = await getReusableContent( - params.spaceId, - params.revisionId, - params.reusableContentId - ); - - if (!reusableContent) { - throw new DataFetcherError('Reusable content not found', 404); - } - - return reusableContent; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const reusableContent = await getReusableContent( + params.spaceId, + params.revisionId, + params.reusableContentId + ); + + if (!reusableContent) { + throw new DataFetcherError('Reusable content not found', 404); + } + + return reusableContent; + }) + ); }, getLatestOpenAPISpecVersionContent(params) { - return wrapDataFetcherError(async () => { - const openAPISpecVersionContent = await getLatestOpenAPISpecVersionContent( - params.organizationId, - params.slug - ); - - if (!openAPISpecVersionContent) { - throw new DataFetcherError('OpenAPI spec version content not found', 404); - } - - return openAPISpecVersionContent; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const openAPISpecVersionContent = await getLatestOpenAPISpecVersionContent( + params.organizationId, + params.slug + ); + + if (!openAPISpecVersionContent) { + throw new DataFetcherError('OpenAPI spec version content not found', 404); + } + + return openAPISpecVersionContent; + }) + ); }, getSiteRedirectBySource(params) { - return wrapDataFetcherError(async () => { - const siteRedirect = await getSiteRedirectBySource(params); - if (!siteRedirect) { - throw new DataFetcherError('Site redirect not found', 404); - } - - return siteRedirect; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const siteRedirect = await getSiteRedirectBySource(params); + if (!siteRedirect) { + throw new DataFetcherError('Site redirect not found', 404); + } + + return siteRedirect; + }) + ); }, getEmbedByUrl(params) { - return wrapDataFetcherError(() => { - return getEmbedByUrlInSpace(params.spaceId, params.url); - }); + return withAPI(() => + wrapDataFetcherError(() => { + return getEmbedByUrlInSpace(params.spaceId, params.url); + }) + ); }, searchSiteContent(params) { - return wrapDataFetcherError(async () => { - const { organizationId, siteId, query, cacheBust, scope } = params; - const result = await searchSiteContent( - organizationId, - siteId, - query, - scope, - cacheBust - ); - return result.items; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const { organizationId, siteId, query, cacheBust, scope } = params; + const result = await searchSiteContent( + organizationId, + siteId, + query, + scope, + cacheBust + ); + return result.items; + }) + ); }, renderIntegrationUi(params) { - return wrapDataFetcherError(async () => { - const result = await renderIntegrationUi(params.integrationName, params.request); - return result; - }); + return withAPI(() => + wrapDataFetcherError(async () => { + const result = await renderIntegrationUi( + params.integrationName, + params.request + ); + return result; + }) + ); }, streamAIResponse() {