UNPKG

@oberoncms/core

Version:

OberonCMS is a cloud deployable CMS written in typescript based on the Puck visual editor

438 lines (372 loc) 12.5 kB
import { z } from "zod" import { Route } from "next" import type { ReactNode } from "react" import type { BetterAuthOptions } from "better-auth" import type { ComponentConfig, Config, Data, DefaultComponentProps, DefaultComponents, SlotComponent, } from "@puckeditor/core" import type { StreamResponseChunk } from "@tohuhono/utils" import type { NextRequest } from "next/server" export class OberonError extends Error {} export class ResponseError extends Error {} export class NotImplementedError extends ResponseError {} // TODO fix types // eslint-disable-next-line @typescript-eslint/no-explicit-any type Transforms = Array<(props: any) => any> export type PageData = Data export type OberonConfig< Components extends DefaultComponents = DefaultComponents, > = Config<{ components: Components }> & { version: 1 components: Record< string, { transforms?: Transforms } > } export type OberonComponent< Props extends DefaultComponentProps = DefaultComponentProps, > = ComponentConfig<{ props: Props }> type OberonFieldMap = Record<string, { type: string }> type InferOberonFieldProp<FieldConfig> = FieldConfig extends { type: "slot" } ? SlotComponent : FieldConfig extends { type: "richtext" } ? ReactNode : FieldConfig extends { type: "textarea"; contentEditable: true } ? ReactNode : FieldConfig extends { type: "text" | "textarea" } ? string : FieldConfig extends { type: "number" } ? number : unknown type InferOberonRequiredFieldKeys<FieldMap extends OberonFieldMap> = { [Key in keyof FieldMap]: FieldMap[Key] extends { type: "slot" } ? Key : never }[keyof FieldMap] type InferOberonOptionalFieldKeys<FieldMap extends OberonFieldMap> = Exclude< keyof FieldMap, InferOberonRequiredFieldKeys<FieldMap> > type InferOberonFieldProps<FieldMap extends OberonFieldMap> = { [Key in InferOberonRequiredFieldKeys<FieldMap>]: InferOberonFieldProp< FieldMap[Key] > } & { [Key in InferOberonOptionalFieldKeys<FieldMap>]?: InferOberonFieldProp< FieldMap[Key] > } type DefineOberonComponentConfig< Props extends DefaultComponentProps, FieldMap extends OberonFieldMap, > = Omit<OberonComponent<Props>, "fields"> & { fields?: FieldMap & NonNullable<OberonComponent<Props>["fields"]> } export function defineOberonComponent<const FieldMap extends OberonFieldMap>( config: DefineOberonComponentConfig< InferOberonFieldProps<FieldMap>, FieldMap >, ): OberonComponent<InferOberonFieldProps<FieldMap>> export function defineOberonComponent< Props extends DefaultComponentProps, const FieldMap extends OberonFieldMap = {}, >(config: DefineOberonComponentConfig<Props, FieldMap>): OberonComponent<Props> export function defineOberonComponent( config: DefineOberonComponentConfig<DefaultComponentProps, OberonFieldMap>, ): OberonComponent<DefaultComponentProps> { return config } export const clientActions = [ "edit", "preview", "users", "images", "pages", "site", "login", ] as const export const actionPaths = clientActions.map((action) => ({ path: [action], })) export type ClientAction = (typeof clientActions)[number] export type AdapterActionGroup = "all" | "users" | "images" | "pages" | "site" export type AdapterPermission = "unauthenticated" | "read" | "write" export type OberonRole = "user" | "admin" | "unauthenticated" | (string & {}) export type OberonPermissions = Record< OberonRole, Partial<Record<AdapterActionGroup, AdapterPermission>> > export const INITIAL_DATA = { content: [], root: { props: { title: "" } }, } satisfies Data export type MaybeOptimistic<T> = T & { pending?: boolean } export const JsonValueSchema = z.json() export type JsonValue = z.infer<typeof JsonValueSchema> /* * Pages */ export const PageSchema = z.object({ key: z .string() .regex(/^[0-9a-zA-Z_.-/]+$/, "Valid characters: 0-9 a-z A-Z -_./") .regex(/^(\/|\/[^/]+(\/[^/]+)*)$/, "Route segments cannot be empty"), data: z.object({}).passthrough(), updatedAt: z.date(), updatedBy: z.string(), }) export const AddPageSchema = PageSchema.pick({ key: true }) export const DeletePageSchema = PageSchema.pick({ key: true }) export const PublishPageSchema = PageSchema.pick({ key: true, data: true }) export const PageMetaSchema = PageSchema.pick({ key: true, updatedAt: true, updatedBy: true, }) export type OberonPage = z.infer<typeof PageSchema> & { data: PageData key: Route } // Cannot infer from zod because we need nextjs to understand key is a valid Route export type OberonPageMeta = MaybeOptimistic< z.infer<typeof PageMetaSchema> & { key: Route } > /* * Images */ export const ImageSchema = z.object({ key: z.string(), url: z.string(), size: z.number(), width: z.number().gt(0), height: z.number().gt(0), alt: z.string(), updatedAt: z.date(), updatedBy: z.string(), }) export const AddImageSchema = ImageSchema export const DeleteImageSchema = ImageSchema.pick({ key: true }) export type OberonImage = MaybeOptimistic<z.infer<typeof ImageSchema>> /* * Users */ export const UserSchema = z.object({ id: z.string(), email: z.string().email(), role: z.union([z.literal("user"), z.literal("admin"), z.string()]), }) export const AddUserSchema = UserSchema.pick({ email: true, role: true }) export const ChangeRoleSchema = UserSchema.pick({ id: true, role: true }) export const DeleteUserSchema = UserSchema.pick({ id: true }) export type OberonUser = MaybeOptimistic<z.infer<typeof UserSchema>> & { role: "user" | "admin" | (string & {}) } export const roles: OberonRole[] = ["user", "admin"] as const /* * Context */ type DescriminatedContext = | { action: "edit" | "preview"; data: Data | null } | { action: "users"; data: OberonUser[] } | { action: "images"; data: OberonImage[] } | { action: "pages"; data: OberonPageMeta[] } | { action: "site"; data: OberonSiteConfig } | { action: "login" data: { callbackUrl: string; email: string; token: string } } export type OberonClientContext = DescriminatedContext & { slug: string } /* * Site */ type TransformStatus = "error" | "success" export type TransformResult = { type: "transform" key: string status: "success" | "error" } export type MigrationResult = { type: "summary" total: number } & { [key in TransformStatus]: string[] } export type TransformVersions = Record<string, number> export type PluginVersion = Pick< ReturnType<OberonPlugin>, "name" | "version" | "disabled" > export type OberonSiteConfig = MaybeOptimistic<{ version: string plugins: PluginVersion[] components: TransformVersions pendingMigrations: string[] | false }> export const SiteSchema = z.object({ version: z.number(), components: z.record(z.string(), z.number()), updatedAt: z.date(), updatedBy: z.string(), }) export type OberonSite = z.infer<typeof SiteSchema> /* * Adapter */ export type OberonCanAdapter = { getCurrentUser: () => Promise<OberonUser | null> hasPermission: (props: { user?: OberonUser | null action: AdapterActionGroup permission: AdapterPermission }) => boolean signIn: (data: { email: string }) => Promise<void> signOut: () => Promise<void> } export type OberonBetterAuthAdapter = Pick<BetterAuthOptions, "database"> export type OberonAuthAdapter = { betterAuth?: OberonBetterAuthAdapter addUser: (data: z.infer<typeof AddUserSchema>) => Promise<OberonUser> deleteUser: (id: OberonUser["id"]) => Promise<void> changeRole: (data: z.infer<typeof ChangeRoleSchema>) => Promise<void> getAllUsers: () => Promise<OberonUser[]> } export type OberonBaseAdapter = { addPage: (page: OberonPage) => Promise<void> addImage: (data: z.infer<typeof ImageSchema>) => Promise<void> deletePage: (key: OberonPageMeta["key"]) => Promise<void> deleteImage: (key: OberonImage["key"]) => Promise<void> // TODO uploadthing deleteKV: (namespace: string, key: string) => Promise<void> getAllImages: () => Promise<OberonImage[]> getAllPages: () => Promise<OberonPageMeta[]> getPageData: (key: OberonPageMeta["key"]) => Promise<Data | null> getKV: (namespace: string, key: string) => Promise<JsonValue | null> getSite: () => Promise<OberonSite | undefined> putKV: (namespace: string, key: string, value: JsonValue) => Promise<void> updatePageData: (data: OberonPage) => Promise<void> updateSite: (data: z.infer<typeof SiteSchema>) => Promise<void> } export type OberonSendAdapter = { sendVerificationRequest: (props: { email: string token: string url: string }) => Promise<void> } export type OberonInitAdapter = { prebuild: () => Promise<void> } export type OberonDatabaseAdapter = Partial<OberonInitAdapter> & OberonBaseAdapter & OberonAuthAdapter export type OberonPluginAdapter = OberonInitAdapter & OberonDatabaseAdapter & OberonCanAdapter & OberonSendAdapter export type OberonMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" export type OberonHandler<Params = undefined> = Params extends undefined ? { [key in OberonMethod]?: (req: NextRequest) => Promise<Response> | Response } : { [key in OberonMethod]: ( req: NextRequest, context: { params: Promise<Params> }, ) => Promise<Response> } export type OberonAdapter = { prebuild: () => Promise<void> getSetting: (namespace: string, key: string) => Promise<JsonValue | null> addPage: (page: z.infer<typeof AddPageSchema>) => Promise<void> addImage: (data: OberonImage) => Promise<OberonImage[]> addUser: (data: z.infer<typeof AddUserSchema>) => Promise<OberonUser | null> deletePage: (data: z.infer<typeof DeletePageSchema>) => Promise<void> deleteImage: (key: OberonImage["key"]) => Promise<void> // TODO uploadthing deleteUser: ( data: z.infer<typeof DeleteUserSchema>, ) => Promise<Pick<OberonUser, "id"> | null> can: ( action: AdapterActionGroup, permission?: AdapterPermission, ) => Promise<boolean> changeRole: ( data: z.infer<typeof ChangeRoleSchema>, ) => Promise<Pick<OberonUser, "role" | "id"> | null> getAllImages: () => Promise<OberonImage[]> getAllPages: () => Promise<OberonPageMeta[]> getAllPaths: () => Promise<Array<{ path: string[] }>> getAllUsers: () => Promise<OberonUser[]> getConfig: () => Promise<OberonSiteConfig> getPageData: (key: OberonPageMeta["key"]) => Promise<Data | null> migrateData: () => Promise< StreamResponseChunk<TransformResult | MigrationResult> > publishPageData: ( data: z.infer<typeof PublishPageSchema>, ) => Promise<{ message: string }> signOut: () => Promise<void> signIn: (data: { email: string }) => Promise<void> } export type OberonPlugin = (adapter: OberonPluginAdapter) => { name: string version?: string disabled?: boolean handlers?: Record<string, (adapter: OberonAdapter) => OberonHandler> adapter?: Partial<OberonPluginAdapter> } export type OberonResponse<T = unknown> = Promise< | { status: "success" result: T message?: string } | { status: "error" result?: T message?: string } > export type OberonServerActions = { addPage: (page: z.infer<typeof AddPageSchema>) => OberonResponse<void> addImage: (data: OberonImage) => OberonResponse<OberonImage[]> addUser: ( data: z.infer<typeof AddUserSchema>, ) => OberonResponse<OberonUser | null> deletePage: (data: z.infer<typeof DeletePageSchema>) => OberonResponse deleteImage: (key: OberonImage["key"]) => OberonResponse deleteUser: ( data: z.infer<typeof DeleteUserSchema>, ) => OberonResponse<Pick<OberonUser, "id"> | null> can: ( action: AdapterActionGroup, permission?: AdapterPermission, ) => OberonResponse<boolean> changeRole: ( data: z.infer<typeof ChangeRoleSchema>, ) => OberonResponse<Pick<OberonUser, "role" | "id"> | null> getAllImages: () => OberonResponse<OberonImage[]> getAllPages: () => OberonResponse<OberonPageMeta[]> getAllPaths: () => OberonResponse<Array<{ path: string[] }>> getAllUsers: () => OberonResponse<OberonUser[]> getConfig: () => OberonResponse<OberonSiteConfig> getPageData: (key: OberonPageMeta["key"]) => OberonResponse<Data | null> migrateData: () => OberonResponse< StreamResponseChunk<TransformResult | MigrationResult> > publishPageData: (data: z.infer<typeof PublishPageSchema>) => OberonResponse signIn: (data: { email: string }) => OberonResponse signOut: () => OberonResponse }