Support for reusable content across spaces. (#3173)

This commit is contained in:
Steven H
2025-04-24 16:54:11 +01:00
committed by GitHub
parent 326e28e9b0
commit 3119066728
10 changed files with 291 additions and 141 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": minor
"gitbook": minor
---
Add support for reusable content across spaces.
+2 -2
View File
@@ -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"],
+1 -1
View File
@@ -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"
},
@@ -20,6 +20,8 @@ export const contentKitServerContext: ContentKitServerContext = {
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
eye: (props) => <Icon icon="eye" {...props} />,
lock: (props) => <Icon icon="lock" {...props} />,
check: (props) => <Icon icon="check" {...props} />,
'check-circle': (props) => <Icon icon="check-circle" {...props} />,
},
codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
@@ -13,15 +13,28 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
throw new Error('Expected a content context to render a reusable content block');
}
const resolved = await resolveContentRef(block.data.ref, context.contentContext);
if (!resolved?.reusableContent?.document) {
const dataFetcher = block.meta?.token
? context.contentContext.dataFetcher.withToken({ apiToken: block.meta.token })
: context.contentContext.dataFetcher;
const resolved = await resolveContentRef(block.data.ref, {
...context.contentContext,
dataFetcher,
});
if (!resolved?.reusableContent) {
return null;
}
const reusableContent = resolved.reusableContent.revisionReusableContent;
if (!reusableContent.document) {
return null;
}
const document = await getDataOrNull(
context.contentContext.dataFetcher.getDocument({
spaceId: context.contentContext.space.id,
documentId: resolved.reusableContent.document,
dataFetcher.getDocument({
spaceId: resolved.reusableContent.space,
documentId: reusableContent.document,
})
);
+24
View File
@@ -28,6 +28,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
],
},
],
permissions: {
edit: false,
},
},
multiWeight: {
@@ -81,6 +84,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
],
},
],
permissions: {
edit: false,
},
},
multiSource: {
@@ -99,6 +105,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
],
},
],
permissions: {
edit: false,
},
},
missingFormat: {
@@ -117,6 +126,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
],
},
],
permissions: {
edit: false,
},
},
empty: {
@@ -124,6 +136,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
custom: true,
fontFamily: 'Empty Font',
fontFaces: [],
permissions: {
edit: false,
},
},
specialChars: {
@@ -136,6 +151,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
sources: [{ url: 'https://example.com/fonts/special.woff2', format: 'woff2' }],
},
],
permissions: {
edit: false,
},
},
complex: {
@@ -158,6 +176,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
],
},
],
permissions: {
edit: false,
},
},
variousURLs: {
@@ -174,6 +195,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
],
},
],
permissions: {
edit: false,
},
},
};
+3 -3
View File
@@ -1,9 +1,9 @@
import type { CustomizationFontDefinition } from '@gitbook/api';
import type { CustomizationFontDefinitionInput } from '@gitbook/api';
/**
* Define the custom font faces and set the --font-custom to the custom font name
*/
export function generateFontFacesCSS(customFont: CustomizationFontDefinition): string {
export function generateFontFacesCSS(customFont: CustomizationFontDefinitionInput): string {
const { fontFaces } = customFont;
// Generate font face declarations for all weights
@@ -45,7 +45,7 @@ export function generateFontFacesCSS(customFont: CustomizationFontDefinition): s
/**
* Get a list of font sources to preload (only 400 and 700 weights)
*/
export function getFontSourcesToPreload(customFont: CustomizationFontDefinition) {
export function getFontSourcesToPreload(customFont: CustomizationFontDefinitionInput) {
return customFont.fontFaces.filter(
(face): face is typeof face & { weight: 400 | 700 } =>
face.weight === 400 || face.weight === 700
+1
View File
@@ -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 : '<no-token>'}`
);
+41 -6
View File
@@ -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,
},
};
}
+193 -124
View File
@@ -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<GitBookBaseContext> {
return url;
};
const dataFetcher = await getDataFetcherV1();
const dataFetcher = getDataFetcherV1();
const imageResizer = createImageResizer({
imagesContextId: host,
@@ -82,77 +84,121 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> {
* 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<GitBookDataFetcher> {
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<T>(fn: () => Promise<T>): Promise<T> {
// 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 withAPI(() =>
wrapDataFetcherError(async () => {
const user = await getUserById(userId);
if (!user) {
throw new DataFetcherError('User not found', 404);
}
return user;
});
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 withAPI(() =>
wrapDataFetcherError(async () => {
const changeRequest = await getChangeRequest(
params.spaceId,
params.changeRequestId
);
if (!changeRequest) {
throw new DataFetcherError('Change request not found', 404);
}
return changeRequest;
});
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 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;
});
return revisionFile;
})
);
},
getRevisionPageMarkdown() {
@@ -160,117 +206,140 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
},
getDocument(params) {
return wrapDataFetcherError(async () => {
const document = await getDocument(params.spaceId, params.documentId);
if (!document) {
throw new DataFetcherError('Document not found', 404);
}
return withAPI(() =>
wrapDataFetcherError(async () => {
const document = await getDocument(params.spaceId, params.documentId);
if (!document) {
throw new DataFetcherError('Document not found', 404);
}
return document;
});
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
);
return withAPI(() =>
wrapDataFetcherError(async () => {
const revisionPage = await getRevisionPageByPath(
params.spaceId,
params.revisionId,
params.path
);
if (!revisionPage) {
throw new DataFetcherError('Revision page not found', 404);
}
if (!revisionPage) {
throw new DataFetcherError('Revision page not found', 404);
}
return revisionPage;
});
return revisionPage;
})
);
},
getReusableContent(params) {
return wrapDataFetcherError(async () => {
const reusableContent = await getReusableContent(
params.spaceId,
params.revisionId,
params.reusableContentId
);
return withAPI(() =>
wrapDataFetcherError(async () => {
const reusableContent = await getReusableContent(
params.spaceId,
params.revisionId,
params.reusableContentId
);
if (!reusableContent) {
throw new DataFetcherError('Reusable content not found', 404);
}
if (!reusableContent) {
throw new DataFetcherError('Reusable content not found', 404);
}
return reusableContent;
});
return reusableContent;
})
);
},
getLatestOpenAPISpecVersionContent(params) {
return wrapDataFetcherError(async () => {
const openAPISpecVersionContent = await getLatestOpenAPISpecVersionContent(
params.organizationId,
params.slug
);
return withAPI(() =>
wrapDataFetcherError(async () => {
const openAPISpecVersionContent = await getLatestOpenAPISpecVersionContent(
params.organizationId,
params.slug
);
if (!openAPISpecVersionContent) {
throw new DataFetcherError('OpenAPI spec version content not found', 404);
}
if (!openAPISpecVersionContent) {
throw new DataFetcherError('OpenAPI spec version content not found', 404);
}
return openAPISpecVersionContent;
});
return openAPISpecVersionContent;
})
);
},
getSiteRedirectBySource(params) {
return wrapDataFetcherError(async () => {
const siteRedirect = await getSiteRedirectBySource(params);
if (!siteRedirect) {
throw new DataFetcherError('Site redirect not found', 404);
}
return withAPI(() =>
wrapDataFetcherError(async () => {
const siteRedirect = await getSiteRedirectBySource(params);
if (!siteRedirect) {
throw new DataFetcherError('Site redirect not found', 404);
}
return siteRedirect;
});
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() {