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": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"@gitbook/api": "0.111.0", "@gitbook/api": "0.113.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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=="], "@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"], "@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
+1 -1
View File
@@ -10,7 +10,7 @@
"packageManager": "bun@1.2.8", "packageManager": "bun@1.2.8",
"overrides": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"@gitbook/api": "0.111.0", "@gitbook/api": "0.113.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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} />, 'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
eye: (props) => <Icon icon="eye" {...props} />, eye: (props) => <Icon icon="eye" {...props} />,
lock: (props) => <Icon icon="lock" {...props} />, lock: (props) => <Icon icon="lock" {...props} />,
check: (props) => <Icon icon="check" {...props} />,
'check-circle': (props) => <Icon icon="check-circle" {...props} />,
}, },
codeBlock: (props) => { codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />; 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'); throw new Error('Expected a content context to render a reusable content block');
} }
const resolved = await resolveContentRef(block.data.ref, context.contentContext); const dataFetcher = block.meta?.token
if (!resolved?.reusableContent?.document) { ? 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; return null;
} }
const document = await getDataOrNull( const document = await getDataOrNull(
context.contentContext.dataFetcher.getDocument({ dataFetcher.getDocument({
spaceId: context.contentContext.space.id, spaceId: resolved.reusableContent.space,
documentId: resolved.reusableContent.document, documentId: reusableContent.document,
}) })
); );
+24
View File
@@ -28,6 +28,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
], ],
}, },
], ],
permissions: {
edit: false,
},
}, },
multiWeight: { multiWeight: {
@@ -81,6 +84,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
], ],
}, },
], ],
permissions: {
edit: false,
},
}, },
multiSource: { multiSource: {
@@ -99,6 +105,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
], ],
}, },
], ],
permissions: {
edit: false,
},
}, },
missingFormat: { missingFormat: {
@@ -117,6 +126,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
], ],
}, },
], ],
permissions: {
edit: false,
},
}, },
empty: { empty: {
@@ -124,6 +136,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
custom: true, custom: true,
fontFamily: 'Empty Font', fontFamily: 'Empty Font',
fontFaces: [], fontFaces: [],
permissions: {
edit: false,
},
}, },
specialChars: { specialChars: {
@@ -136,6 +151,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
sources: [{ url: 'https://example.com/fonts/special.woff2', format: 'woff2' }], sources: [{ url: 'https://example.com/fonts/special.woff2', format: 'woff2' }],
}, },
], ],
permissions: {
edit: false,
},
}, },
complex: { complex: {
@@ -158,6 +176,9 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
], ],
}, },
], ],
permissions: {
edit: false,
},
}, },
variousURLs: { 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 * 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; const { fontFaces } = customFont;
// Generate font face declarations for all weights // 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) * 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( return customFont.fontFaces.filter(
(face): face is typeof face & { weight: 400 | 700 } => (face): face is typeof face & { weight: 400 | 700 } =>
face.weight === 400 || face.weight === 700 face.weight === 400 || face.weight === 700
+1
View File
@@ -267,6 +267,7 @@ export const getPublishedContentByUrl = cache({
const parsed = parseCacheResponse(response); const parsed = parseCacheResponse(response);
// biome-ignore lint/suspicious/noConsole: log the ttl of the token
console.log( console.log(
`Parsed ttl: ${parsed.ttl} at ${Date.now()}, for ${'apiToken' in response.data ? response.data.apiToken : '<no-token>'}` `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; file?: RevisionFile;
/** Page document resolved from the content ref */ /** Page document resolved from the content ref */
page?: RevisionPageDocument; page?: RevisionPageDocument;
/** Resolved reusable content, if the ref points to reusable content on a revision. */ /** Resolved reusable content, if the ref points to reusable content on a revision. Also contains the space and revision used for resolution. */
reusableContent?: RevisionReusableContent; reusableContent?: {
revisionReusableContent: RevisionReusableContent;
space: string;
revision: string;
};
/** Resolve OpenAPI spec filesystem. */ /** Resolve OpenAPI spec filesystem. */
openAPIFilesystem?: Filesystem; openAPIFilesystem?: Filesystem;
} }
@@ -231,21 +235,52 @@ export async function resolveContentRef(
} }
case 'reusable-content': { 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( const reusableContent = await getDataOrNull(
dataFetcher.getReusableContent({ dataFetcher.getReusableContent({
spaceId: space.id, spaceId: container.space,
revisionId, revisionId: container.revision,
reusableContentId: contentRef.reusableContent, reusableContentId: contentRef.reusableContent,
}) })
); );
if (!reusableContent) { if (!reusableContent) {
return null; return null;
} }
return { return {
href: getGitBookAppHref(`/s/${space.id}`), href: getGitBookAppHref(`/s/${container.space}/~/reusable/${reusableContent.id}`),
text: reusableContent.title, text: reusableContent.title,
active: false, 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 { createImageResizer } from '@v2/lib/images';
import { createLinker } from '@v2/lib/links'; import { createLinker } from '@v2/lib/links';
import { GitBookAPI } from '@gitbook/api';
import { DataFetcherError, wrapDataFetcherError } from '@v2/lib/data'; import { DataFetcherError, wrapDataFetcherError } from '@v2/lib/data';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { import {
@@ -30,6 +31,7 @@ import {
getUserById, getUserById,
renderIntegrationUi, renderIntegrationUi,
searchSiteContent, searchSiteContent,
withAPI as withAPIV1,
} from './api'; } from './api';
import { getDynamicCustomizationSettings } from './customization'; import { getDynamicCustomizationSettings } from './customization';
import { withLeadingSlash, withTrailingSlash } from './paths'; import { withLeadingSlash, withTrailingSlash } from './paths';
@@ -58,7 +60,7 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> {
return url; return url;
}; };
const dataFetcher = await getDataFetcherV1(); const dataFetcher = getDataFetcherV1();
const imageResizer = createImageResizer({ const imageResizer = createImageResizer({
imagesContextId: host, 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. * 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. * 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 = { const dataFetcher: GitBookDataFetcher = {
async api() { async api() {
const result = await api(); return withAPI(async () => {
return result.client; const result = await api();
return result.client;
});
}, },
withToken() { withToken({ apiToken }) {
// In v1, the token is global and controlled by the middleware. return getDataFetcherV1(apiToken);
// We don't need to do anything special here.
return dataFetcher;
}, },
getUserById(userId) { getUserById(userId) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const user = await getUserById(userId); wrapDataFetcherError(async () => {
if (!user) { const user = await getUserById(userId);
throw new DataFetcherError('User not found', 404); if (!user) {
} throw new DataFetcherError('User not found', 404);
}
return user; return user;
}); })
);
}, },
getPublishedContentSite(params) { getPublishedContentSite(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
return getPublishedContentSite(params); wrapDataFetcherError(async () => {
}); return getPublishedContentSite(params);
})
);
}, },
getSpace(params) { getSpace(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
return getSpace(params.spaceId, params.shareKey); wrapDataFetcherError(async () => {
}); return getSpace(params.spaceId, params.shareKey);
})
);
}, },
getChangeRequest(params) { getChangeRequest(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const changeRequest = await getChangeRequest( wrapDataFetcherError(async () => {
params.spaceId, const changeRequest = await getChangeRequest(
params.changeRequestId params.spaceId,
); params.changeRequestId
if (!changeRequest) { );
throw new DataFetcherError('Change request not found', 404); if (!changeRequest) {
} throw new DataFetcherError('Change request not found', 404);
}
return changeRequest; return changeRequest;
}); })
);
}, },
getRevision(params) { getRevision(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
return getRevision(params.spaceId, params.revisionId, { wrapDataFetcherError(async () => {
metadata: params.metadata, return getRevision(params.spaceId, params.revisionId, {
}); metadata: params.metadata,
}); });
})
);
}, },
getRevisionFile(params) { getRevisionFile(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const revisionFile = await getRevisionFile( wrapDataFetcherError(async () => {
params.spaceId, const revisionFile = await getRevisionFile(
params.revisionId, params.spaceId,
params.fileId params.revisionId,
); params.fileId
if (!revisionFile) { );
throw new DataFetcherError('Revision file not found', 404); if (!revisionFile) {
} throw new DataFetcherError('Revision file not found', 404);
}
return revisionFile; return revisionFile;
}); })
);
}, },
getRevisionPageMarkdown() { getRevisionPageMarkdown() {
@@ -160,117 +206,140 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
}, },
getDocument(params) { getDocument(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const document = await getDocument(params.spaceId, params.documentId); wrapDataFetcherError(async () => {
if (!document) { const document = await getDocument(params.spaceId, params.documentId);
throw new DataFetcherError('Document not found', 404); if (!document) {
} throw new DataFetcherError('Document not found', 404);
}
return document; return document;
}); })
);
}, },
getComputedDocument(params) { getComputedDocument(params) {
return wrapDataFetcherError(() => { return withAPI(() =>
return getComputedDocument( wrapDataFetcherError(() => {
params.organizationId, return getComputedDocument(
params.spaceId, params.organizationId,
params.source, params.spaceId,
params.seed params.source,
); params.seed
}); );
})
);
}, },
getRevisionPages(params) { getRevisionPages(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
return getRevisionPages(params.spaceId, params.revisionId, { wrapDataFetcherError(async () => {
metadata: params.metadata, return getRevisionPages(params.spaceId, params.revisionId, {
}); metadata: params.metadata,
}); });
})
);
}, },
getRevisionPageByPath(params) { getRevisionPageByPath(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const revisionPage = await getRevisionPageByPath( wrapDataFetcherError(async () => {
params.spaceId, const revisionPage = await getRevisionPageByPath(
params.revisionId, params.spaceId,
params.path params.revisionId,
); params.path
);
if (!revisionPage) { if (!revisionPage) {
throw new DataFetcherError('Revision page not found', 404); throw new DataFetcherError('Revision page not found', 404);
} }
return revisionPage; return revisionPage;
}); })
);
}, },
getReusableContent(params) { getReusableContent(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const reusableContent = await getReusableContent( wrapDataFetcherError(async () => {
params.spaceId, const reusableContent = await getReusableContent(
params.revisionId, params.spaceId,
params.reusableContentId params.revisionId,
); params.reusableContentId
);
if (!reusableContent) { if (!reusableContent) {
throw new DataFetcherError('Reusable content not found', 404); throw new DataFetcherError('Reusable content not found', 404);
} }
return reusableContent; return reusableContent;
}); })
);
}, },
getLatestOpenAPISpecVersionContent(params) { getLatestOpenAPISpecVersionContent(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const openAPISpecVersionContent = await getLatestOpenAPISpecVersionContent( wrapDataFetcherError(async () => {
params.organizationId, const openAPISpecVersionContent = await getLatestOpenAPISpecVersionContent(
params.slug params.organizationId,
); params.slug
);
if (!openAPISpecVersionContent) { if (!openAPISpecVersionContent) {
throw new DataFetcherError('OpenAPI spec version content not found', 404); throw new DataFetcherError('OpenAPI spec version content not found', 404);
} }
return openAPISpecVersionContent; return openAPISpecVersionContent;
}); })
);
}, },
getSiteRedirectBySource(params) { getSiteRedirectBySource(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const siteRedirect = await getSiteRedirectBySource(params); wrapDataFetcherError(async () => {
if (!siteRedirect) { const siteRedirect = await getSiteRedirectBySource(params);
throw new DataFetcherError('Site redirect not found', 404); if (!siteRedirect) {
} throw new DataFetcherError('Site redirect not found', 404);
}
return siteRedirect; return siteRedirect;
}); })
);
}, },
getEmbedByUrl(params) { getEmbedByUrl(params) {
return wrapDataFetcherError(() => { return withAPI(() =>
return getEmbedByUrlInSpace(params.spaceId, params.url); wrapDataFetcherError(() => {
}); return getEmbedByUrlInSpace(params.spaceId, params.url);
})
);
}, },
searchSiteContent(params) { searchSiteContent(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const { organizationId, siteId, query, cacheBust, scope } = params; wrapDataFetcherError(async () => {
const result = await searchSiteContent( const { organizationId, siteId, query, cacheBust, scope } = params;
organizationId, const result = await searchSiteContent(
siteId, organizationId,
query, siteId,
scope, query,
cacheBust scope,
); cacheBust
return result.items; );
}); return result.items;
})
);
}, },
renderIntegrationUi(params) { renderIntegrationUi(params) {
return wrapDataFetcherError(async () => { return withAPI(() =>
const result = await renderIntegrationUi(params.integrationName, params.request); wrapDataFetcherError(async () => {
return result; const result = await renderIntegrationUi(
}); params.integrationName,
params.request
);
return result;
})
);
}, },
streamAIResponse() { streamAIResponse() {