369 lines
9.4 KiB
TypeScript
369 lines
9.4 KiB
TypeScript
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<string, boolean>();
|
|
const parentCache = new Map<string, string | null>();
|
|
|
|
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<Cms.ContentItem> {
|
|
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<Cms.ContentItem> {
|
|
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: [],
|
|
};
|
|
}
|
|
}
|