import { Cms, CmsClient } from '@kit/cms-types'; import { createKeystaticReader } from './create-reader'; import { DocumentationEntryProps, PostEntryProps } from './keystatic.config'; import { renderMarkdoc } from './markdoc'; export function createKeystaticClient() { return new KeystaticClient(); } class KeystaticClient implements CmsClient { async getContentItems(options: Cms.GetContentItemsOptions) { const reader = await createKeystaticReader(); const collection = options.collection as keyof (typeof reader)['collections']; if (!reader.collections[collection]) { throw new Error(`Collection ${collection} not found`); } const fetchContent = options.content ?? true; const startOffset = options?.offset ?? 0; const endOffset = startOffset + (options?.limit ?? 10); const docs = await reader.collections[collection].all(); const filtered = docs .filter((item) => { const status = options?.status ?? 'published'; if (item.entry.status !== status) { return false; } const categoryMatch = options?.categories?.length ? options.categories.find((category) => item.entry.categories.includes(category), ) : true; if (!categoryMatch) { return false; } if (options.language) { if (item.entry.language && item.entry.language !== options.language) { return false; } } const tagMatch = options?.tags?.length ? options.tags.find((tag) => item.entry.tags.includes(tag)) : true; if (!tagMatch) { return false; } return true; }) .sort((a, b) => { const direction = options.sortDirection ?? 'asc'; const sortBy = options.sortBy ?? 'publishedAt'; const transform = (value: string | number | undefined | null) => { if (typeof value === 'string') { return new Date(value).getTime(); } return value ?? 0; }; const left = transform(a.entry[sortBy]); const right = transform(b.entry[sortBy]); if (direction === 'asc') { return left - right; } return right - left; }); function processItems(items: typeof filtered) { const slugSet = new Set(items.map((item) => item.slug)); const indexFileCache = new Map(); const parentCache = new Map(); const isIndexFile = (slug: string): boolean => { if (indexFileCache.has(slug)) { return indexFileCache.get(slug)!; } const parts = slug.split('/'); const result = parts.length === 1 || (parts.length >= 2 && parts[parts.length - 1] === parts[parts.length - 2]); indexFileCache.set(slug, result); return result; }; const findClosestValidParent = (pathParts: string[]): string | null => { const path = pathParts.join('/'); if (parentCache.has(path)) { return parentCache.get(path)!; } for (let i = pathParts.length - 1; i > 0; i--) { const parentParts = pathParts.slice(0, i); const lastPart = parentParts[parentParts.length - 1]; if (!lastPart) { continue; } const possibleIndexParent = parentParts.concat(lastPart).join('/'); if (slugSet.has(possibleIndexParent)) { parentCache.set(path, possibleIndexParent); return possibleIndexParent; } const regularParent = parentParts.join('/'); if (slugSet.has(regularParent)) { parentCache.set(path, regularParent); return regularParent; } } parentCache.set(path, null); return null; }; const results = new Array(items.length) as typeof items; for (let i = 0; i < items.length; i++) { const item = items[i]; if (!item) { continue; } // If the parent is already set, we don't need to do anything if (item.entry.parent !== null) { continue; } if (isIndexFile(item.slug)) { item.entry.parent = null; results[i] = item; continue; } const pathParts = item.slug.split('/'); const parentParts = pathParts.slice(0, -1); const lastPart = parentParts[parentParts.length - 1]!; const possibleIndexParent = parentParts.concat(lastPart).join('/'); if (slugSet.has(possibleIndexParent)) { item.entry.parent = possibleIndexParent; } else { const regularParent = parentParts.join('/'); if (slugSet.has(regularParent)) { item.entry.parent = regularParent; } else { item.entry.parent = findClosestValidParent(pathParts); } } results[i] = item; } return results; } const itemsWithParents = processItems(filtered); const items = await Promise.all( itemsWithParents .slice(startOffset, endOffset) .sort((a, b) => { return (a.entry.order ?? 0) - (b.entry.order ?? 0); }) .map((item) => { if (collection === 'documentation') { return this.mapDocumentationPost( item as { entry: DocumentationEntryProps; slug: string; }, { fetchContent }, ); } return this.mapPost( item as { entry: PostEntryProps; slug: string; }, { fetchContent }, ); }), ); return { total: filtered.length, items, }; } async getContentItemBySlug(params: { slug: string; collection: string; status?: Cms.ContentItemStatus; }) { const reader = await createKeystaticReader(); const collection = params.collection as keyof (typeof reader)['collections']; if (!reader.collections[collection]) { throw new Error(`Collection ${collection} not found`); } const doc = await reader.collections[collection].read(params.slug); const status = params.status ?? 'published'; // verify that the document exists if (!doc) { return Promise.resolve(undefined); } // check the document matches the status provided in the params if (doc.status !== status) { return Promise.resolve(undefined); } return this.mapPost({ entry: doc as PostEntryProps, slug: params.slug }); } async getCategories() { return Promise.resolve([]); } async getTags() { return Promise.resolve([]); } async getTagBySlug() { return Promise.resolve(undefined); } async getCategoryBySlug() { return Promise.resolve(undefined); } private async mapDocumentationPost< Type extends { entry: DocumentationEntryProps; slug: string; }, >( item: Type, params: { fetchContent: boolean; } = { fetchContent: true, }, ): Promise { const publishedAt = item.entry.publishedAt ? new Date(item.entry.publishedAt) : new Date(); const content = await item.entry.content(); const html = params.fetchContent ? await renderMarkdoc(content.node) : []; return { id: item.slug, title: item.entry.title, label: item.entry.label, url: item.slug, slug: item.slug, description: item.entry.description, publishedAt: publishedAt.toISOString(), content: html as string, image: item.entry.image ?? undefined, status: item.entry.status, collapsible: item.entry.collapsible, collapsed: item.entry.collapsed, categories: (item.entry.categories ?? []).map((item) => { return { id: item, name: item, slug: item, }; }) ?? [], tags: (item.entry.tags ?? []).map((item) => { return { id: item, name: item, slug: item, }; }), parentId: item.entry.parent ?? undefined, order: item.entry.order ?? 1, children: [], }; } private async mapPost< Type extends { entry: PostEntryProps; slug: string; }, >( item: Type, params: { fetchContent: boolean; } = { fetchContent: true, }, ): Promise { const publishedAt = item.entry.publishedAt ? new Date(item.entry.publishedAt) : new Date(); const content = await item.entry.content(); const html = params.fetchContent ? await renderMarkdoc(content.node) : []; return { id: item.slug, title: item.entry.title, label: item.entry.label, url: item.slug, slug: item.slug, description: item.entry.description, publishedAt: publishedAt.toISOString(), content: html as string, image: item.entry.image ?? undefined, status: item.entry.status, categories: (item.entry.categories ?? []).map((item) => { return { id: item, name: item, slug: item, }; }) ?? [], tags: (item.entry.tags ?? []).map((item) => { return { id: item, name: item, slug: item, }; }), parentId: item.entry.parent ?? undefined, order: item.entry.order ?? 1, children: [], }; } }