koguma 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * contentType, group, defineConfig, Infer — the schema definition API.
3
+ */
4
+ import { z } from 'zod/v4';
5
+ import type { FieldBuilder, FieldMeta } from './field.ts';
6
+
7
+ // ── Group ────────────────────────────────────────────────────────────
8
+
9
+ export interface GroupConfig {
10
+ groupId: string;
11
+ name: string;
12
+ helpText?: string;
13
+ collapsed?: boolean;
14
+ fields: Record<string, FieldBuilder>;
15
+ }
16
+
17
+ export function group(opts: {
18
+ id: string;
19
+ name: string;
20
+ helpText?: string;
21
+ collapsed?: boolean;
22
+ fields: Record<string, FieldBuilder>;
23
+ }): GroupConfig {
24
+ return {
25
+ groupId: opts.id,
26
+ name: opts.name,
27
+ helpText: opts.helpText,
28
+ collapsed: opts.collapsed,
29
+ fields: opts.fields
30
+ };
31
+ }
32
+
33
+ // ── ContentType ──────────────────────────────────────────────────────
34
+
35
+ export interface ContentTypeConfig<S extends z.ZodType = z.ZodType> {
36
+ id: string;
37
+ name: string;
38
+ displayField: string;
39
+ singleton?: boolean;
40
+ /** The resolved Zod schema for z.infer */
41
+ schema: S;
42
+ /** Field metadata for the admin + schema generator */
43
+ fieldMeta: Record<string, FieldMeta>;
44
+ /** Group layout for the admin */
45
+ groups: GroupConfig[];
46
+ /** Flat fields (no groups) — for simple content types */
47
+ flatFields: Record<string, FieldBuilder>;
48
+ }
49
+
50
+ /**
51
+ * Define a content type.
52
+ *
53
+ * Fields can be defined directly (flat) or organized into groups:
54
+ *
55
+ * ```ts
56
+ * // Flat
57
+ * contentType("card", {
58
+ * name: "Card",
59
+ * displayField: "title",
60
+ * fields: {
61
+ * title: field.text("Title").required(),
62
+ * body: field.richText("Body"),
63
+ * },
64
+ * });
65
+ *
66
+ * // Grouped
67
+ * contentType("page", {
68
+ * name: "Page",
69
+ * displayField: "title",
70
+ * fields: [
71
+ * group({ id: "hero", name: "1 · Hero", fields: { title: field.text("Title") } }),
72
+ * group({ id: "about", name: "2 · About", fields: { heading: field.text("Heading") } }),
73
+ * ],
74
+ * });
75
+ * ```
76
+ */
77
+ export function contentType<
78
+ F extends Record<string, FieldBuilder> | GroupConfig[]
79
+ >(opts: {
80
+ id: string;
81
+ name: string;
82
+ displayField: string;
83
+ singleton?: boolean;
84
+ fields: F;
85
+ }): ContentTypeConfig {
86
+ // Collect all field builders (from groups or flat)
87
+ const allFields: Record<string, FieldBuilder> = {};
88
+ const groups: GroupConfig[] = [];
89
+ let flatFields: Record<string, FieldBuilder> = {};
90
+
91
+ if (Array.isArray(opts.fields)) {
92
+ // Grouped fields
93
+ for (const g of opts.fields) {
94
+ groups.push(g);
95
+ Object.assign(allFields, g.fields);
96
+ }
97
+ } else {
98
+ // Flat fields
99
+ flatFields = opts.fields as Record<string, FieldBuilder>;
100
+ Object.assign(allFields, flatFields);
101
+ }
102
+
103
+ // Build Zod object schema from all fields
104
+ const shape: Record<string, z.ZodType> = {};
105
+ const fieldMeta: Record<string, FieldMeta> = {};
106
+
107
+ for (const [key, builder] of Object.entries(allFields)) {
108
+ shape[key] = builder._schema;
109
+ fieldMeta[key] = builder._meta;
110
+ }
111
+
112
+ const schema = z.object(shape);
113
+
114
+ return {
115
+ id: opts.id,
116
+ name: opts.name,
117
+ displayField: opts.displayField,
118
+ singleton: opts.singleton,
119
+ schema: schema as z.ZodType,
120
+ fieldMeta,
121
+ groups,
122
+ flatFields
123
+ };
124
+ }
125
+
126
+ // ── defineConfig ─────────────────────────────────────────────────────
127
+
128
+ export interface KogumaConfig {
129
+ siteName: string;
130
+ contentTypes: ContentTypeConfig[];
131
+ hooks?: {
132
+ beforeSave?: (
133
+ entry: Record<string, unknown>,
134
+ ctx: { contentType: string }
135
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
136
+ afterSave?: (
137
+ entry: Record<string, unknown>,
138
+ ctx: { contentType: string }
139
+ ) => void | Promise<void>;
140
+ };
141
+ }
142
+
143
+ export function defineConfig(config: KogumaConfig): KogumaConfig {
144
+ return config;
145
+ }
146
+
147
+ // ── Infer ────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Extract the TypeScript type from a content type definition.
151
+ *
152
+ * ```ts
153
+ * const page = contentType({ ... });
154
+ * type Page = Infer<typeof page>;
155
+ * ```
156
+ */
157
+ export type Infer<T extends ContentTypeConfig> = z.infer<T['schema']>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * field.* builders — the user-facing API for defining content fields.
3
+ *
4
+ * Each method returns a FieldBuilder that wraps a Zod schema internally.
5
+ * The admin label is always the first argument for readability:
6
+ *
7
+ * field.text("Page Title").required().max(100)
8
+ * field.richText("Body Text")
9
+ * field.image("Hero Photo")
10
+ * field.refs("featureCard", "Highlights")
11
+ */
12
+ import { z } from "zod/v4";
13
+ import type { KogumaAsset, RichTextDocument, EntryReference } from "./types.ts";
14
+
15
+ // ── Field metadata (extracted by the admin + schema generator) ──────
16
+
17
+ export type FieldType =
18
+ | "text"
19
+ | "longText"
20
+ | "richText"
21
+ | "url"
22
+ | "image"
23
+ | "boolean"
24
+ | "number"
25
+ | "date"
26
+ | "select"
27
+ | "reference"
28
+ | "references";
29
+
30
+ export interface FieldMeta {
31
+ label: string;
32
+ fieldType: FieldType;
33
+ required: boolean;
34
+ refContentType?: string;
35
+ options?: string[];
36
+ }
37
+
38
+ // ── FieldBuilder ────────────────────────────────────────────────────
39
+
40
+ export class FieldBuilder<T extends z.ZodType = z.ZodType> {
41
+ /** @internal */
42
+ readonly _schema: T;
43
+ /** @internal */
44
+ readonly _meta: FieldMeta;
45
+
46
+ constructor(schema: T, meta: FieldMeta) {
47
+ this._schema = schema;
48
+ this._meta = meta;
49
+ }
50
+
51
+ /** Mark as required (fields are optional by default) */
52
+ required(): FieldBuilder<T> {
53
+ return new FieldBuilder(this._schema, { ...this._meta, required: true });
54
+ }
55
+
56
+ /** Set maximum length (text) or value (number) */
57
+ max(value: number): FieldBuilder<T> {
58
+ const s = this._schema;
59
+ if (s instanceof z.ZodString) {
60
+ return new FieldBuilder(s.max(value) as unknown as T, this._meta);
61
+ }
62
+ if (s instanceof z.ZodNumber) {
63
+ return new FieldBuilder(s.max(value) as unknown as T, this._meta);
64
+ }
65
+ return this;
66
+ }
67
+
68
+ /** Set minimum length (text) or value (number) */
69
+ min(value: number): FieldBuilder<T> {
70
+ const s = this._schema;
71
+ if (s instanceof z.ZodString) {
72
+ return new FieldBuilder(s.min(value) as unknown as T, this._meta);
73
+ }
74
+ if (s instanceof z.ZodNumber) {
75
+ return new FieldBuilder(s.min(value) as unknown as T, this._meta);
76
+ }
77
+ if (s instanceof z.ZodArray) {
78
+ return new FieldBuilder(s.min(value) as unknown as T, this._meta);
79
+ }
80
+ return this;
81
+ }
82
+
83
+ /** Set a default value */
84
+ default(value: z.infer<T>): FieldBuilder {
85
+ return new FieldBuilder((this._schema as z.ZodType).default(value as never), this._meta);
86
+ }
87
+ }
88
+
89
+ // ── Factory functions ──────────────────────────────────────────────
90
+
91
+ export const field = {
92
+ /** Short text field */
93
+ text(label: string) {
94
+ return new FieldBuilder(
95
+ z.string().optional().describe(label),
96
+ { label, fieldType: "text", required: false },
97
+ );
98
+ },
99
+
100
+ /** Multi-line text field */
101
+ longText(label: string) {
102
+ return new FieldBuilder(
103
+ z.string().optional().describe(label),
104
+ { label, fieldType: "longText", required: false },
105
+ );
106
+ },
107
+
108
+ /** Rich text field (Tiptap JSON document) */
109
+ richText(label: string) {
110
+ return new FieldBuilder(
111
+ z.custom<RichTextDocument>().optional().describe(label),
112
+ { label, fieldType: "richText", required: false },
113
+ );
114
+ },
115
+
116
+ /** URL field with validation */
117
+ url(label: string) {
118
+ return new FieldBuilder(
119
+ z.string().url().optional().describe(label),
120
+ { label, fieldType: "url", required: false },
121
+ );
122
+ },
123
+
124
+ /** Image/media field (reference to R2 asset) */
125
+ image(label: string) {
126
+ return new FieldBuilder(
127
+ z.custom<KogumaAsset>().optional().describe(label),
128
+ { label, fieldType: "image", required: false },
129
+ );
130
+ },
131
+
132
+ /** Boolean toggle */
133
+ boolean(label: string) {
134
+ return new FieldBuilder(
135
+ z.boolean().optional().describe(label),
136
+ { label, fieldType: "boolean", required: false },
137
+ );
138
+ },
139
+
140
+ /** Numeric field */
141
+ number(label: string) {
142
+ return new FieldBuilder(
143
+ z.number().optional().describe(label),
144
+ { label, fieldType: "number", required: false },
145
+ );
146
+ },
147
+
148
+ /** Date field */
149
+ date(label: string) {
150
+ return new FieldBuilder(
151
+ z.string().datetime().optional().describe(label),
152
+ { label, fieldType: "date", required: false },
153
+ );
154
+ },
155
+
156
+ /** Dropdown select */
157
+ select(label: string, opts: { options: string[] }) {
158
+ const enumSchema = z.enum(opts.options as [string, ...string[]]).optional().describe(label);
159
+ return new FieldBuilder(enumSchema, {
160
+ label,
161
+ fieldType: "select",
162
+ required: false,
163
+ options: opts.options,
164
+ });
165
+ },
166
+
167
+ /** Single reference to another content type */
168
+ ref(contentType: string, label: string) {
169
+ return new FieldBuilder(
170
+ z.custom<EntryReference>().optional().describe(label),
171
+ { label, fieldType: "reference", required: false, refContentType: contentType },
172
+ );
173
+ },
174
+
175
+ /** Ordered array of references to another content type */
176
+ refs(contentType: string, label: string) {
177
+ return new FieldBuilder(
178
+ z.array(z.custom<EntryReference>()).optional().describe(label),
179
+ { label, fieldType: "references", required: false, refContentType: contentType },
180
+ );
181
+ },
182
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Koguma — public API
3
+ *
4
+ * Everything the user needs comes from this single import:
5
+ *
6
+ * import { defineConfig, contentType, group, field, type Infer } from "koguma";
7
+ */
8
+
9
+ // Config helpers
10
+ export { defineConfig, contentType, group } from './define.ts';
11
+ export type {
12
+ Infer,
13
+ KogumaConfig,
14
+ ContentTypeConfig,
15
+ GroupConfig
16
+ } from './define.ts';
17
+
18
+ // Field builders
19
+ export { field } from './field.ts';
20
+ export type { FieldBuilder, FieldType, FieldMeta } from './field.ts';
21
+
22
+ // Runtime types
23
+ export type { KogumaAsset, RichTextDocument, EntryReference } from './types.ts';
24
+
25
+ // Meta registry (for dev tools like the Schema Builder)
26
+ export { fieldRegistry, fieldSuggestions } from './meta.ts';
27
+ export type { FieldTypeMeta, FieldSuggestion } from './meta.ts';
@@ -0,0 +1,189 @@
1
+ /**
2
+ * koguma/meta — field type registry for tooling.
3
+ *
4
+ * This module exports metadata about every field type Koguma supports.
5
+ * The Schema Builder (and any future dev tools) reads this registry
6
+ * instead of hardcoding field type lists.
7
+ *
8
+ * Zero breaking changes — purely additive. Infer<T> and all existing
9
+ * APIs are untouched.
10
+ */
11
+ import type { FieldType } from './field.ts';
12
+
13
+ // ── Field Type Metadata ─────────────────────────────────────────────
14
+
15
+ export interface FieldTypeMeta {
16
+ type: FieldType;
17
+ label: string;
18
+ icon: string;
19
+ description: string;
20
+ category: 'basics' | 'data' | 'relations';
21
+ args: 'label' | 'label+options' | 'ref+label';
22
+ modifiers: ('required' | 'min' | 'max' | 'default')[];
23
+ }
24
+
25
+ export const fieldRegistry: FieldTypeMeta[] = [
26
+ // Basics
27
+ {
28
+ type: 'text',
29
+ label: 'Text',
30
+ icon: 'Aa',
31
+ category: 'basics',
32
+ description: 'Short text',
33
+ args: 'label',
34
+ modifiers: ['required', 'min', 'max', 'default']
35
+ },
36
+ {
37
+ type: 'longText',
38
+ label: 'Long Text',
39
+ icon: '¶',
40
+ category: 'basics',
41
+ description: 'Multi-line text',
42
+ args: 'label',
43
+ modifiers: ['required', 'min', 'max', 'default']
44
+ },
45
+ {
46
+ type: 'richText',
47
+ label: 'Rich Text',
48
+ icon: '📝',
49
+ category: 'basics',
50
+ description: 'Rich text editor',
51
+ args: 'label',
52
+ modifiers: ['required']
53
+ },
54
+ // Data
55
+ {
56
+ type: 'url',
57
+ label: 'URL',
58
+ icon: '🔗',
59
+ category: 'data',
60
+ description: 'URL with validation',
61
+ args: 'label',
62
+ modifiers: ['required']
63
+ },
64
+ {
65
+ type: 'boolean',
66
+ label: 'Boolean',
67
+ icon: '☑',
68
+ category: 'data',
69
+ description: 'True/false toggle',
70
+ args: 'label',
71
+ modifiers: ['required', 'default']
72
+ },
73
+ {
74
+ type: 'number',
75
+ label: 'Number',
76
+ icon: '#',
77
+ category: 'data',
78
+ description: 'Numeric value',
79
+ args: 'label',
80
+ modifiers: ['required', 'min', 'max', 'default']
81
+ },
82
+ {
83
+ type: 'date',
84
+ label: 'Date',
85
+ icon: '📅',
86
+ category: 'data',
87
+ description: 'Date picker',
88
+ args: 'label',
89
+ modifiers: ['required']
90
+ },
91
+ {
92
+ type: 'select',
93
+ label: 'Select',
94
+ icon: '▾',
95
+ category: 'data',
96
+ description: 'Dropdown options',
97
+ args: 'label+options',
98
+ modifiers: ['required', 'default']
99
+ },
100
+ // Relations
101
+ {
102
+ type: 'image',
103
+ label: 'Image',
104
+ icon: '🖼️',
105
+ category: 'relations',
106
+ description: 'Image from R2',
107
+ args: 'label',
108
+ modifiers: ['required']
109
+ },
110
+ {
111
+ type: 'reference',
112
+ label: 'Reference',
113
+ icon: '→',
114
+ category: 'relations',
115
+ description: 'Link to entry',
116
+ args: 'ref+label',
117
+ modifiers: ['required']
118
+ },
119
+ {
120
+ type: 'references',
121
+ label: 'References',
122
+ icon: '⇉',
123
+ category: 'relations',
124
+ description: 'List of links',
125
+ args: 'ref+label',
126
+ modifiers: ['required', 'min']
127
+ }
128
+ ];
129
+
130
+ // ── Intent-based field suggestions ───────────────────────────────────
131
+
132
+ export interface FieldSuggestion {
133
+ type: FieldType;
134
+ modifiers?: string;
135
+ }
136
+
137
+ /**
138
+ * Maps common label keywords to suggested field types.
139
+ * The builder uses this to auto-select the type dropdown when
140
+ * the user types a label.
141
+ */
142
+ export const fieldSuggestions: Record<string, FieldSuggestion> = {
143
+ // Text fields
144
+ title: { type: 'text', modifiers: 'required' },
145
+ name: { type: 'text', modifiers: 'required' },
146
+ slug: { type: 'text', modifiers: 'required' },
147
+ heading: { type: 'text' },
148
+ tagline: { type: 'text' },
149
+ // Long text
150
+ description: { type: 'longText' },
151
+ bio: { type: 'longText' },
152
+ notes: { type: 'longText' },
153
+ excerpt: { type: 'longText' },
154
+ // Rich text
155
+ body: { type: 'richText' },
156
+ content: { type: 'richText' },
157
+ story: { type: 'richText' },
158
+ // URL
159
+ email: { type: 'url' },
160
+ website: { type: 'url' },
161
+ url: { type: 'url' },
162
+ link: { type: 'url' },
163
+ // Image
164
+ photo: { type: 'image' },
165
+ image: { type: 'image' },
166
+ avatar: { type: 'image' },
167
+ cover: { type: 'image' },
168
+ logo: { type: 'image' },
169
+ poster: { type: 'image' },
170
+ // Boolean
171
+ published: { type: 'boolean', modifiers: 'default(false)' },
172
+ active: { type: 'boolean', modifiers: 'default(true)' },
173
+ featured: { type: 'boolean', modifiers: 'default(false)' },
174
+ // Number
175
+ price: { type: 'number', modifiers: 'min(0)' },
176
+ weight: { type: 'number' },
177
+ count: { type: 'number' },
178
+ order: { type: 'number' },
179
+ // Date
180
+ date: { type: 'date' },
181
+ birthday: { type: 'date' },
182
+ // Select
183
+ category: { type: 'select' },
184
+ status: { type: 'select' },
185
+ role: { type: 'select' },
186
+ type: { type: 'select' },
187
+ genre: { type: 'select' },
188
+ format: { type: 'select' }
189
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Core CMS types used across Koguma.
3
+ * These are the runtime types for assets and rich text — not Zod schemas.
4
+ */
5
+
6
+ /** A media asset stored in R2 */
7
+ export interface KogumaAsset {
8
+ id: string;
9
+ url: string;
10
+ title: string;
11
+ contentType: string;
12
+ width?: number;
13
+ height?: number;
14
+ }
15
+
16
+ /** A Tiptap/ProseMirror-compatible rich text document */
17
+ export interface RichTextDocument {
18
+ type: "doc";
19
+ content: RichTextNode[];
20
+ }
21
+
22
+ export interface RichTextNode {
23
+ type: string;
24
+ content?: RichTextNode[];
25
+ text?: string;
26
+ marks?: { type: string; attrs?: Record<string, unknown> }[];
27
+ attrs?: Record<string, unknown>;
28
+ }
29
+
30
+ /** Reference to another entry */
31
+ export interface EntryReference<T = Record<string, unknown>> {
32
+ id: string;
33
+ contentType: string;
34
+ fields?: T;
35
+ }