@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
text/typescript
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
}