B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,3 @@
export function KeystaticContentRenderer(props: { content: unknown }) {
return props.content as React.ReactNode;
}

View File

@@ -0,0 +1,37 @@
import { CmsClient } from '@kit/cms-types';
/**
* Creates a new Keystatic client instance.
*/
export async function createKeystaticClient() {
if (
process.env.NEXT_RUNTIME === 'nodejs' ||
process.env.KEYSTATIC_STORAGE_KIND !== 'local'
) {
const { createKeystaticClient: createClient } = await import(
'./keystatic-client'
);
return createClient();
}
console.error(
`[CMS] Keystatic client using "Local" mode is only available in Node.js runtime. Please choose a different CMS client. Returning a mock client instead of throwing an error.`,
);
return mockCMSClient() as unknown as CmsClient;
}
function mockCMSClient() {
return {
getContentItems() {
return Promise.resolve({
items: [],
total: 0,
});
},
getContentItemBySlug() {
return Promise.resolve(undefined);
},
};
}

View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
import { KeystaticStorage } from './keystatic-storage';
import { keyStaticConfig } from './keystatic.config';
/**
* @name createKeystaticReader
* @description Creates a new Keystatic reader instance.
*/
export async function createKeystaticReader() {
switch (KeystaticStorage.kind) {
case 'local': {
// we need to import this dynamically to avoid parsing the package in edge environments
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { createReader } = await import('@keystatic/core/reader');
return createReader(process.cwd(), keyStaticConfig);
} else {
// we should never get here but the compiler requires the check
// to ensure we don't parse the package at build time
throw new Error();
}
}
case 'github':
case 'cloud': {
const { createGitHubReader } = await import(
'@keystatic/core/reader/github'
);
return createGitHubReader(
keyStaticConfig,
getKeystaticGithubConfiguration(),
);
}
default:
throw new Error(`Unknown storage kind`);
}
}
function getKeystaticGithubConfiguration() {
/**
* @description The repository to use for the GitHub storage.
* This can be provided through the `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` environment variable. The previous environment variable `KEYSTATIC_STORAGE_REPO` is deprecated.
*/
const repo =
process.env.NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO ??
/* @deprecated */
process.env.KEYSTATIC_STORAGE_REPO;
return z
.object({
token: z.string({
description:
'The GitHub token to use for authentication. Please provide the value through the "KEYSTATIC_GITHUB_TOKEN" environment variable.',
}),
repo: z.custom<`${string}/${string}`>(),
pathPrefix: z.string().optional(),
})
.parse({
token: process.env.KEYSTATIC_GITHUB_TOKEN,
repo,
pathPrefix: process.env.KEYSTATIC_PATH_PREFIX,
});
}

View File

@@ -0,0 +1,36 @@
import type { ComponentType } from 'react';
import type { Schema } from '@markdoc/markdoc';
type Component = ComponentType<unknown>;
/**
* @name CustomMarkdocComponents
* @description Custom components for Markdoc. Please define your custom components here.
* @example
*
* ```ts
* function Youtube(props: { src: string }) { ... }
*
* export const CustomMarkdocComponents: Record<string, React.ComponentType<never>> = {
* Youtube,
* };
*/
export const CustomMarkdocComponents: Record<string, Component> = {
// define your custom components here
};
/**
* @name CustomMarkdocTags
* @description Custom tags for Markdoc. Please define your custom tags here.
* @example
* export const CustomMarkdocTags = {
* youtube: {
* render: "Youtube",
* selfClosing: true,
* }
* }
*/
export const CustomMarkdocTags: Record<string, Schema> = {
// define your custom tags here
};

View File

@@ -0,0 +1 @@
export * from './create-keystatic-cms';

View File

@@ -0,0 +1,7 @@
'use client';
import { makePage } from '@keystatic/next/ui/app';
import { keyStaticConfig } from './keystatic.config';
export default makePage(keyStaticConfig);

View File

@@ -0,0 +1,368 @@
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: [],
};
}
}

View File

@@ -0,0 +1,29 @@
import { makeRouteHandler } from '@keystatic/next/route-handler';
import { keyStaticConfig } from './keystatic.config';
const handlers = makeRouteHandler({
config: keyStaticConfig,
});
/**
* @name productionGuard
* @description Guard for production environment. Returns 404 if in production.
* @param routeHandler
*/
function productionGuard(routeHandler: (req: Request) => Promise<Response>) {
if (process.env.NODE_ENV === 'production') {
return new Response('Not found', { status: 404 });
}
return (req: Request) => routeHandler(req);
}
/**
* @name keystaticRouteHandlers
* @description Route handlers for keystatic
*/
export const keystaticRouteHandlers = {
POST: productionGuard(handlers.POST),
GET: productionGuard(handlers.GET),
};

View File

@@ -0,0 +1,79 @@
import { CloudConfig, GitHubConfig, LocalConfig } from '@keystatic/core';
import { z } from 'zod';
type ZodOutputFor<T> = z.ZodType<T, z.ZodTypeDef, unknown>;
/**
* @name STORAGE_KIND
* @description The kind of storage to use for the Keystatic reader.
*
* This can be provided through the `KEYSTATIC_STORAGE_KIND` environment variable or 'NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND'.
* The previous environment variable `KEYSTATIC_STORAGE_KIND` is deprecated - as Keystatic may need this to be available in the client-side.
*
*/
const STORAGE_KIND =
process.env.NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND ??
/* @deprecated */
process.env.KEYSTATIC_STORAGE_KIND ??
'local';
/**
* @name REPO
* @description The repository to use for the GitHub storage.
* This can be provided through the `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` environment variable. The previous environment variable `KEYSTATIC_STORAGE_REPO` is deprecated.
*/
const REPO =
process.env.NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO ??
/* @deprecated */
process.env.KEYSTATIC_STORAGE_REPO;
const BRANCH_PREFIX = process.env.KEYSTATIC_STORAGE_BRANCH_PREFIX;
const PATH_PREFIX = process.env.KEYSTATIC_PATH_PREFIX;
const PROJECT = process.env.KEYSTATIC_STORAGE_PROJECT;
/**
* @name local
* @description The configuration for the local storage.
*/
const local = z.object({
kind: z.literal('local'),
}) satisfies ZodOutputFor<LocalConfig['storage']>;
/**
* @name cloud
* @description The configuration for the cloud storage.
*/
const cloud = z.object({
kind: z.literal('cloud'),
project: z
.string({
description: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`,
})
.min(1),
branchPrefix: z.string().optional(),
pathPrefix: z.string().optional(),
}) satisfies ZodOutputFor<CloudConfig['storage']>;
/**
* @name github
* @description The configuration for the GitHub storage.
*/
const github = z.object({
kind: z.literal('github'),
repo: z.custom<`${string}/${string}`>(),
branchPrefix: z.string().optional(),
pathPrefix: z.string().optional(),
}) satisfies ZodOutputFor<GitHubConfig['storage']>;
/**
* @name KeystaticStorage
* @description The configuration for the Keystatic storage. This is used to determine where the content is stored.
* This configuration is validated through Zod to ensure that the configuration is correct.
*/
export const KeystaticStorage = z.union([local, cloud, github]).parse({
kind: STORAGE_KIND,
project: PROJECT,
repo: REPO,
branchPrefix: BRANCH_PREFIX,
pathPrefix: PATH_PREFIX,
});

View File

@@ -0,0 +1,153 @@
import { collection, config, fields } from '@keystatic/core';
import { Entry } from '@keystatic/core/reader';
import { KeystaticStorage } from './keystatic-storage';
export const keyStaticConfig = createKeyStaticConfig(
process.env.NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH ?? '',
);
function getContentField() {
return fields.markdoc({
label: 'Content',
options: {
link: true,
blockquote: true,
bold: true,
divider: true,
orderedList: true,
unorderedList: true,
strikethrough: true,
heading: true,
code: true,
italic: true,
image: {
directory: 'public/site/images',
publicPath: '/site/images',
schema: {
title: fields.text({
label: 'Caption',
description: 'The text to display under the image in a caption.',
}),
},
},
},
});
}
export type PostEntryProps = Entry<
(typeof keyStaticConfig)['collections']['posts']
>;
export type DocumentationEntryProps = Entry<
(typeof keyStaticConfig)['collections']['documentation']
>;
function createKeyStaticConfig(path = '') {
if (path && !path.endsWith('/')) {
path += '/';
}
const cloud = {
project: KeystaticStorage.kind === 'cloud' ? KeystaticStorage.project : '',
};
const collections = getKeystaticCollections(path);
return config({
storage: KeystaticStorage,
cloud,
collections,
});
}
function getKeystaticCollections(path: string) {
return {
posts: collection({
label: 'Posts',
slugField: 'title',
path: `${path}posts/*`,
format: { contentField: 'content' },
schema: {
title: fields.slug({ name: { label: 'Title' } }),
label: fields.text({
label: 'Label',
validation: { isRequired: false },
}),
image: fields.image({
label: 'Image',
directory: 'public/site/images',
publicPath: '/site/images',
}),
categories: fields.array(fields.text({ label: 'Category' })),
tags: fields.array(fields.text({ label: 'Tag' })),
description: fields.text({ label: 'Description' }),
publishedAt: fields.date({ label: 'Published At' }),
parent: fields.relationship({
label: 'Parent',
collection: 'posts',
}),
language: fields.text({ label: 'Language' }),
order: fields.number({ label: 'Order' }),
content: getContentField(),
status: fields.select({
defaultValue: 'draft',
label: 'Status',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
{ label: 'Review', value: 'review' },
{ label: 'Pending', value: 'pending' },
],
}),
},
}),
documentation: collection({
label: 'Documentation',
slugField: 'title',
path: `${path}documentation/**`,
format: { contentField: 'content' },
schema: {
title: fields.slug({ name: { label: 'Title' } }),
label: fields.text({
label: 'Label',
validation: { isRequired: false },
}),
content: getContentField(),
image: fields.image({
label: 'Image',
directory: 'public/site/images',
publicPath: '/site/images',
}),
description: fields.text({ label: 'Description' }),
publishedAt: fields.date({ label: 'Published At' }),
order: fields.number({ label: 'Order' }),
language: fields.text({ label: 'Language' }),
parent: fields.relationship({
label: 'Parent',
collection: 'documentation',
}),
categories: fields.array(fields.text({ label: 'Category' })),
tags: fields.array(fields.text({ label: 'Tag' })),
status: fields.select({
defaultValue: 'draft',
label: 'Status',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
{ label: 'Review', value: 'review' },
{ label: 'Pending', value: 'pending' },
],
}),
collapsible: fields.checkbox({
label: 'Collapsible',
defaultValue: false,
}),
collapsed: fields.checkbox({
label: 'Collapsed',
defaultValue: false,
}),
},
}),
};
}

View File

@@ -0,0 +1,42 @@
// Or replace this with your own function
import { Config, Node, RenderableTreeNode, Tag } from '@markdoc/markdoc';
function generateID(
children: Array<RenderableTreeNode>,
attributes: Record<string, unknown>,
) {
if (attributes.id && typeof attributes.id === 'string') {
return attributes.id;
}
return children
.filter((child) => typeof child === 'string')
.join(' ')
.replace(/[?]/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
}
const heading = {
children: ['inline'],
attributes: {
id: { type: String },
level: { type: Number, required: true, default: 1 },
},
transform(node: Node, config: Config) {
const attributes = node.transformAttributes(config);
const children = node.transformChildren(config);
const id = generateID(children, attributes);
return new Tag(
`h${node.attributes.level}`,
{ ...attributes, id },
children,
);
},
};
export const MarkdocNodes = {
heading,
};

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Node } from '@markdoc/markdoc';
import {
CustomMarkdocComponents,
CustomMarkdocTags,
} from './custom-components';
import { MarkdocNodes } from './markdoc-nodes';
/**
* @name renderMarkdoc
* @description Renders a Markdoc tree to React
*/
export async function renderMarkdoc(node: Node) {
const { transform, renderers } = await import('@markdoc/markdoc');
const content = transform(node, {
tags: {
...CustomMarkdocTags,
},
nodes: {
...MarkdocNodes,
},
});
return renderers.react(content, React, {
components: CustomMarkdocComponents,
});
}