Some changes ported from the work on the makerkit.dev website related… (#89)

* Some changes ported from the work on the makerkit.dev website related to the marketing sections of the kit, such as documentation
* Added slight background hue to make darker theme better looking
* Support more complex configurations for documentation navigations.
* Do not fetch content from Keystatic when non-needed
* Add cursor pointers in dropdown
* Updated packages
This commit is contained in:
Giancarlo Buomprisco
2024-12-09 05:58:17 +01:00
committed by GitHub
parent a682b991f3
commit 079a8f857a
44 changed files with 762 additions and 456 deletions

View File

@@ -1,7 +1,7 @@
import { Cms, CmsClient } from '@kit/cms-types';
import { createKeystaticReader } from './create-reader';
import { PostEntryProps } from './keystatic.config';
import { DocumentationEntryProps, PostEntryProps } from './keystatic.config';
import { renderMarkdoc } from './markdoc';
export function createKeystaticClient() {
@@ -19,11 +19,13 @@ class KeystaticClient implements CmsClient {
throw new Error(`Collection ${collection} not found`);
}
const docs = await reader.collections[collection].all();
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';
@@ -80,72 +82,95 @@ class KeystaticClient implements CmsClient {
return right - left;
});
function processItems(items: typeof docs) {
const result: typeof docs = [...items];
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 indexFiles = items.filter((item) => {
const parts = item.slug.split('/');
const isIndexFile = (slug: string): boolean => {
if (indexFileCache.has(slug)) {
return indexFileCache.get(slug)!;
}
return (
parts.length > 1 &&
parts[parts.length - 1] === parts[parts.length - 2]
);
});
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)!;
}
function findParentIndex(pathParts: string[]): string | null {
// Try each level up from the current path until we find an index file
for (let i = pathParts.length - 1; i > 0; i--) {
const currentPath = pathParts.slice(0, i).join('/');
const parentParts = pathParts.slice(0, i);
const lastPart = parentParts[parentParts.length - 1];
const possibleParent = indexFiles.find((indexFile) => {
const indexParts = indexFile.slug.split('/');
const indexFolderPath = indexParts.slice(0, -1).join('/');
if (!lastPart) {
continue;
}
return indexFolderPath === currentPath;
});
const possibleIndexParent = parentParts.concat(lastPart).join('/');
if (possibleParent) {
return possibleParent.slug;
if (slugSet.has(possibleIndexParent)) {
parentCache.set(path, possibleIndexParent);
return possibleIndexParent;
}
const regularParent = parentParts.join('/');
if (slugSet.has(regularParent)) {
parentCache.set(path, regularParent);
return regularParent;
}
}
return null;
}
result.forEach((item) => {
// never override the parent if it's already set in the config
if (item.entry.parent) {
return;
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 (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('/');
// Skip if this is a root level index file (e.g., "authentication/authentication")
if (pathParts.length === 2 && pathParts[0] === pathParts[1]) {
item.entry.parent = null;
return;
}
// Check if current item is an index file
const isIndexFile =
pathParts[pathParts.length - 1] === pathParts[pathParts.length - 2];
if (isIndexFile) {
// For index files, find parent in the level above
const parentPath = pathParts.slice(0, -2);
if (parentPath.length > 0) {
item.entry.parent = findParentIndex(
parentPath.concat(parentPath[parentPath.length - 1]!),
);
} else {
item.entry.parent = null;
}
if (slugSet.has(possibleIndexParent)) {
item.entry.parent = possibleIndexParent;
} else {
// For regular files, find parent in the current folder
item.entry.parent = findParentIndex(pathParts);
const regularParent = parentParts.join('/');
if (slugSet.has(regularParent)) {
item.entry.parent = regularParent;
} else {
item.entry.parent = findClosestValidParent(pathParts);
}
}
});
results[i] = item;
}
return result;
return results;
}
const itemsWithParents = processItems(filtered);
@@ -156,7 +181,25 @@ class KeystaticClient implements CmsClient {
.sort((a, b) => {
return (a.entry.order ?? 0) - (b.entry.order ?? 0);
})
.map((item) => this.mapPost(item)),
.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 {
@@ -192,13 +235,7 @@ class KeystaticClient implements CmsClient {
return Promise.resolve(undefined);
}
const allPosts = await reader.collections[collection].all();
const children = allPosts.filter(
(item) => item.entry.parent === params.slug,
);
return this.mapPost({ entry: doc, slug: params.slug }, children);
return this.mapPost({ entry: doc as PostEntryProps, slug: params.slug });
}
async getCategories() {
@@ -217,18 +254,79 @@ class KeystaticClient implements CmsClient {
return Promise.resolve(undefined);
}
private async mapPost<
private async mapDocumentationPost<
Type extends {
entry: PostEntryProps;
entry: DocumentationEntryProps;
slug: string;
},
>(item: Type, children: Type[] = []): Promise<Cms.ContentItem> {
>(
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 = await renderMarkdoc(content.node);
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,
@@ -242,14 +340,14 @@ class KeystaticClient implements CmsClient {
image: item.entry.image ?? undefined,
status: item.entry.status,
categories:
item.entry.categories.map((item) => {
(item.entry.categories ?? []).map((item) => {
return {
id: item,
name: item,
slug: item,
};
}) ?? [],
tags: item.entry.tags.map((item) => {
tags: (item.entry.tags ?? []).map((item) => {
return {
id: item,
name: item,
@@ -258,9 +356,7 @@ class KeystaticClient implements CmsClient {
}),
parentId: item.entry.parent ?? undefined,
order: item.entry.order ?? 1,
children: await Promise.all(
children.map((child) => this.mapPost(child, [])),
),
children: [],
};
}
}

View File

@@ -39,6 +39,10 @@ export type PostEntryProps = Entry<
(typeof keyStaticConfig)['collections']['posts']
>;
export type DocumentationEntryProps = Entry<
(typeof keyStaticConfig)['collections']['documentation']
>;
function createKeyStaticConfig(path = '') {
if (path && !path.endsWith('/')) {
path += '/';
@@ -135,6 +139,14 @@ function getKeystaticCollections(path: string) {
{ label: 'Pending', value: 'pending' },
],
}),
collapsible: fields.checkbox({
label: 'Collapsible',
defaultValue: false,
}),
collapsed: fields.checkbox({
label: 'Collapsed',
defaultValue: false,
}),
},
}),
};

View File

@@ -16,6 +16,8 @@ export namespace Cms {
order: number;
children: ContentItem[];
parentId: string | undefined;
collapsible?: boolean;
collapsed?: boolean;
}
export type ContentItemStatus = 'draft' | 'published' | 'review' | 'pending';
@@ -38,6 +40,7 @@ export namespace Cms {
offset?: number;
categories?: string[];
tags?: string[];
content?: boolean;
parentIds?: string[];
language?: string | undefined;
sortDirection?: 'asc' | 'desc';