@setzkasten-cms/core 0.4.2

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,59 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createEventBus } from './content-event-bus'
3
+
4
+ describe('ContentEventBus', () => {
5
+ it('emits events to subscribers', () => {
6
+ const bus = createEventBus()
7
+ const handler = vi.fn()
8
+
9
+ bus.on('field-changed', handler)
10
+ bus.emit({ type: 'field-changed', path: ['title'], value: 'Hello' })
11
+
12
+ expect(handler).toHaveBeenCalledWith({
13
+ type: 'field-changed',
14
+ path: ['title'],
15
+ value: 'Hello',
16
+ })
17
+ })
18
+
19
+ it('only notifies matching event types', () => {
20
+ const bus = createEventBus()
21
+ const fieldHandler = vi.fn()
22
+ const saveHandler = vi.fn()
23
+
24
+ bus.on('field-changed', fieldHandler)
25
+ bus.on('entry-saved', saveHandler)
26
+
27
+ bus.emit({ type: 'field-changed', path: ['title'], value: 'Hello' })
28
+
29
+ expect(fieldHandler).toHaveBeenCalledOnce()
30
+ expect(saveHandler).not.toHaveBeenCalled()
31
+ })
32
+
33
+ it('supports unsubscribing', () => {
34
+ const bus = createEventBus()
35
+ const handler = vi.fn()
36
+
37
+ const unsubscribe = bus.on('field-changed', handler)
38
+ bus.emit({ type: 'field-changed', path: ['title'], value: 'Hello' })
39
+ expect(handler).toHaveBeenCalledOnce()
40
+
41
+ unsubscribe()
42
+ bus.emit({ type: 'field-changed', path: ['title'], value: 'World' })
43
+ expect(handler).toHaveBeenCalledOnce() // still just once
44
+ })
45
+
46
+ it('supports multiple subscribers', () => {
47
+ const bus = createEventBus()
48
+ const handler1 = vi.fn()
49
+ const handler2 = vi.fn()
50
+
51
+ bus.on('entry-saved', handler1)
52
+ bus.on('entry-saved', handler2)
53
+
54
+ bus.emit({ type: 'entry-saved', collection: 'posts', slug: 'hello' })
55
+
56
+ expect(handler1).toHaveBeenCalledOnce()
57
+ expect(handler2).toHaveBeenCalledOnce()
58
+ })
59
+ })
@@ -0,0 +1,55 @@
1
+ import type { FieldPath } from '../fields/field-definition'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Content event types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type ContentEvent =
8
+ | { readonly type: 'field-changed'; readonly path: FieldPath; readonly value: unknown }
9
+ | { readonly type: 'entry-saved'; readonly collection: string; readonly slug: string }
10
+ | { readonly type: 'entry-deleted'; readonly collection: string; readonly slug: string }
11
+ | { readonly type: 'entry-loaded'; readonly collection: string; readonly slug: string }
12
+
13
+ export type ContentEventType = ContentEvent['type']
14
+ export type Unsubscribe = () => void
15
+
16
+ /**
17
+ * Observer pattern – decoupled communication between form state and preview engine.
18
+ */
19
+ export interface ContentEventBus {
20
+ emit(event: ContentEvent): void
21
+ on<T extends ContentEventType>(
22
+ type: T,
23
+ handler: (event: Extract<ContentEvent, { type: T }>) => void,
24
+ ): Unsubscribe
25
+ }
26
+
27
+ export function createEventBus(): ContentEventBus {
28
+ const listeners = new Map<string, Set<(event: ContentEvent) => void>>()
29
+
30
+ return {
31
+ emit(event) {
32
+ const handlers = listeners.get(event.type)
33
+ if (handlers) {
34
+ for (const handler of handlers) {
35
+ handler(event)
36
+ }
37
+ }
38
+ },
39
+
40
+ on(type, handler) {
41
+ if (!listeners.has(type)) {
42
+ listeners.set(type, new Set())
43
+ }
44
+ const handlers = listeners.get(type)!
45
+ handlers.add(handler as (event: ContentEvent) => void)
46
+
47
+ return () => {
48
+ handlers.delete(handler as (event: ContentEvent) => void)
49
+ if (handlers.size === 0) {
50
+ listeners.delete(type)
51
+ }
52
+ }
53
+ },
54
+ }
55
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { f } from './factories'
3
+
4
+ describe('Field Factories', () => {
5
+ describe('f.text()', () => {
6
+ it('creates a text field with defaults', () => {
7
+ const field = f.text({ label: 'Title' })
8
+ expect(field.type).toBe('text')
9
+ expect(field.label).toBe('Title')
10
+ expect(field.multiline).toBe(false)
11
+ expect(field.defaultValue).toBe('')
12
+ expect(field.required).toBe(false)
13
+ })
14
+
15
+ it('creates a multiline text field', () => {
16
+ const field = f.text({ label: 'Description', multiline: true, maxLength: 500 })
17
+ expect(field.multiline).toBe(true)
18
+ expect(field.maxLength).toBe(500)
19
+ })
20
+
21
+ it('returns a frozen object', () => {
22
+ const field = f.text({ label: 'Test' })
23
+ expect(Object.isFrozen(field)).toBe(true)
24
+ })
25
+ })
26
+
27
+ describe('f.number()', () => {
28
+ it('creates a number field with defaults', () => {
29
+ const field = f.number({ label: 'Count' })
30
+ expect(field.type).toBe('number')
31
+ expect(field.defaultValue).toBe(0)
32
+ expect(field.slider).toBe(false)
33
+ })
34
+
35
+ it('creates a number field with min/max/step', () => {
36
+ const field = f.number({ label: 'Latitude', min: -90, max: 90, step: 0.0001 })
37
+ expect(field.min).toBe(-90)
38
+ expect(field.max).toBe(90)
39
+ expect(field.step).toBe(0.0001)
40
+ })
41
+ })
42
+
43
+ describe('f.boolean()', () => {
44
+ it('creates a boolean field', () => {
45
+ const field = f.boolean({ label: 'Enabled' })
46
+ expect(field.type).toBe('boolean')
47
+ expect(field.defaultValue).toBe(false)
48
+ })
49
+
50
+ it('accepts custom default', () => {
51
+ const field = f.boolean({ label: 'Active', defaultValue: true })
52
+ expect(field.defaultValue).toBe(true)
53
+ })
54
+ })
55
+
56
+ describe('f.select()', () => {
57
+ it('creates a select field', () => {
58
+ const field = f.select({
59
+ label: 'Style',
60
+ options: [
61
+ { label: 'Primary', value: 'primary' },
62
+ { label: 'Secondary', value: 'secondary' },
63
+ ],
64
+ })
65
+ expect(field.type).toBe('select')
66
+ expect(field.options).toHaveLength(2)
67
+ expect(field.defaultValue).toBe('primary')
68
+ expect(field.multi).toBe(false)
69
+ })
70
+ })
71
+
72
+ describe('f.image()', () => {
73
+ it('creates an image field with defaults', () => {
74
+ const field = f.image({
75
+ label: 'Hero Image',
76
+ directory: 'public/images/hero/',
77
+ })
78
+ expect(field.type).toBe('image')
79
+ expect(field.directory).toBe('public/images/hero/')
80
+ expect(field.accept).toEqual(['webp', 'jpg', 'png'])
81
+ expect(field.filenameStrategy).toBe('original')
82
+ })
83
+
84
+ it('accepts custom accept types', () => {
85
+ const field = f.image({
86
+ label: 'Logo',
87
+ directory: 'public/images/logo/',
88
+ accept: ['svg', 'png'],
89
+ maxSize: '1MB',
90
+ })
91
+ expect(field.accept).toEqual(['svg', 'png'])
92
+ expect(field.maxSize).toBe('1MB')
93
+ })
94
+ })
95
+
96
+ describe('f.array()', () => {
97
+ it('creates an array field', () => {
98
+ const field = f.array(f.text({ label: 'Tag' }), {
99
+ label: 'Tags',
100
+ minItems: 1,
101
+ maxItems: 10,
102
+ })
103
+ expect(field.type).toBe('array')
104
+ expect(field.itemField.type).toBe('text')
105
+ expect(field.minItems).toBe(1)
106
+ expect(field.maxItems).toBe(10)
107
+ })
108
+
109
+ it('supports complex item types', () => {
110
+ const field = f.array(
111
+ f.object(
112
+ {
113
+ name: f.text({ label: 'Name' }),
114
+ url: f.text({ label: 'URL' }),
115
+ },
116
+ { label: 'Link' },
117
+ ),
118
+ { label: 'Links' },
119
+ )
120
+ expect(field.type).toBe('array')
121
+ expect(field.itemField.type).toBe('object')
122
+ })
123
+ })
124
+
125
+ describe('f.object()', () => {
126
+ it('creates an object field', () => {
127
+ const field = f.object(
128
+ {
129
+ street: f.text({ label: 'Street' }),
130
+ city: f.text({ label: 'City' }),
131
+ zip: f.text({ label: 'ZIP' }),
132
+ },
133
+ { label: 'Address', collapsible: true },
134
+ )
135
+ expect(field.type).toBe('object')
136
+ expect(field.collapsible).toBe(true)
137
+ expect(Object.keys(field.fields)).toEqual(['street', 'city', 'zip'])
138
+ })
139
+ })
140
+
141
+ describe('f.override()', () => {
142
+ it('creates an override field with active flag', () => {
143
+ const field = f.override(
144
+ {
145
+ heading: f.text({ label: 'Heading' }),
146
+ subtitle: f.text({ label: 'Subtitle', multiline: true }),
147
+ },
148
+ { label: 'Hero anpassen' },
149
+ )
150
+ expect(field.type).toBe('override')
151
+ expect(field.defaultValue).toEqual({ active: false })
152
+ expect(Object.keys(field.fields)).toEqual(['heading', 'subtitle'])
153
+ })
154
+ })
155
+
156
+ describe('f.color()', () => {
157
+ it('creates a color field', () => {
158
+ const field = f.color({
159
+ label: 'Brand Color',
160
+ defaultValue: '#a2c617',
161
+ presets: ['#a2c617', '#333333', '#ffffff'],
162
+ })
163
+ expect(field.type).toBe('color')
164
+ expect(field.defaultValue).toBe('#a2c617')
165
+ expect(field.presets).toHaveLength(3)
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,166 @@
1
+ import type {
2
+ ArrayFieldDef,
3
+ ArrayFieldOptions,
4
+ BooleanFieldDef,
5
+ BooleanFieldOptions,
6
+ ColorFieldDef,
7
+ ColorFieldOptions,
8
+ FieldDefinition,
9
+ FieldRecord,
10
+ IconFieldDef,
11
+ IconFieldOptions,
12
+ ImageFieldDef,
13
+ ImageFieldOptions,
14
+ NumberFieldDef,
15
+ NumberFieldOptions,
16
+ ObjectFieldDef,
17
+ ObjectFieldOptions,
18
+ OverrideFieldDef,
19
+ OverrideFieldOptions,
20
+ SelectFieldDef,
21
+ SelectFieldOptions,
22
+ TextFieldDef,
23
+ TextFieldOptions,
24
+ } from './field-definition'
25
+
26
+ /**
27
+ * Field factories – the public API for defining content schemas.
28
+ *
29
+ * Each factory returns a frozen FieldDefinition object with a discriminated
30
+ * `type` property. The phantom type parameter carries the runtime value type
31
+ * through the type system for end-to-end inference.
32
+ */
33
+ export const f = {
34
+ text(options: TextFieldOptions): TextFieldDef {
35
+ return Object.freeze({
36
+ type: 'text' as const,
37
+ label: options.label,
38
+ description: options.description,
39
+ defaultValue: options.defaultValue ?? '',
40
+ required: options.required ?? false,
41
+ multiline: options.multiline ?? false,
42
+ placeholder: options.placeholder,
43
+ maxLength: options.maxLength,
44
+ pattern: options.pattern,
45
+ formatting: options.formatting ?? false,
46
+ })
47
+ },
48
+
49
+ number(options: NumberFieldOptions): NumberFieldDef {
50
+ return Object.freeze({
51
+ type: 'number' as const,
52
+ label: options.label,
53
+ description: options.description,
54
+ defaultValue: options.defaultValue ?? 0,
55
+ required: options.required ?? false,
56
+ min: options.min,
57
+ max: options.max,
58
+ step: options.step,
59
+ slider: options.slider ?? false,
60
+ })
61
+ },
62
+
63
+ boolean(options: BooleanFieldOptions): BooleanFieldDef {
64
+ return Object.freeze({
65
+ type: 'boolean' as const,
66
+ label: options.label,
67
+ description: options.description,
68
+ defaultValue: options.defaultValue ?? false,
69
+ })
70
+ },
71
+
72
+ select(options: SelectFieldOptions): SelectFieldDef {
73
+ return Object.freeze({
74
+ type: 'select' as const,
75
+ label: options.label,
76
+ description: options.description,
77
+ defaultValue: options.defaultValue ?? options.options[0]?.value ?? '',
78
+ required: options.required ?? false,
79
+ options: Object.freeze(options.options),
80
+ multi: options.multi ?? false,
81
+ })
82
+ },
83
+
84
+ icon(options: IconFieldOptions): IconFieldDef {
85
+ return Object.freeze({
86
+ type: 'icon' as const,
87
+ label: options.label,
88
+ description: options.description,
89
+ defaultValue: options.defaultValue ?? '',
90
+ required: options.required ?? false,
91
+ })
92
+ },
93
+
94
+ image(options: ImageFieldOptions): ImageFieldDef {
95
+ return Object.freeze({
96
+ type: 'image' as const,
97
+ label: options.label,
98
+ description: options.description,
99
+ required: options.required ?? false,
100
+ directory: options.directory,
101
+ accept: Object.freeze(options.accept ?? ['webp', 'jpg', 'png']),
102
+ maxSize: options.maxSize,
103
+ filenameStrategy: options.filenameStrategy ?? 'original',
104
+ })
105
+ },
106
+
107
+ array<TItem extends FieldDefinition>(
108
+ itemField: TItem,
109
+ options: ArrayFieldOptions,
110
+ ): ArrayFieldDef<TItem> {
111
+ return Object.freeze({
112
+ type: 'array' as const,
113
+ label: options.label,
114
+ description: options.description,
115
+ required: options.required ?? false,
116
+ itemField,
117
+ minItems: options.minItems,
118
+ maxItems: options.maxItems,
119
+ itemLabel: options.itemLabel,
120
+ }) as ArrayFieldDef<TItem>
121
+ },
122
+
123
+ object<TFields extends FieldRecord>(
124
+ fields: TFields,
125
+ options: ObjectFieldOptions,
126
+ ): ObjectFieldDef<TFields> {
127
+ return Object.freeze({
128
+ type: 'object' as const,
129
+ label: options.label,
130
+ description: options.description,
131
+ required: options.required ?? false,
132
+ fields,
133
+ collapsible: options.collapsible ?? false,
134
+ }) as ObjectFieldDef<TFields>
135
+ },
136
+
137
+ color(options: ColorFieldOptions): ColorFieldDef {
138
+ return Object.freeze({
139
+ type: 'color' as const,
140
+ label: options.label,
141
+ description: options.description,
142
+ defaultValue: options.defaultValue ?? '#000000',
143
+ presets: Object.freeze(options.presets ?? []),
144
+ })
145
+ },
146
+
147
+ /**
148
+ * Override field – wraps a set of fields with an "active" checkbox.
149
+ * When inactive, the override fields are hidden and don't affect output.
150
+ * This replaces Keystatic's DOM-hack approach with a native schema concept.
151
+ */
152
+ override<TFields extends FieldRecord>(
153
+ fields: TFields,
154
+ options: OverrideFieldOptions,
155
+ ): OverrideFieldDef<TFields> {
156
+ return Object.freeze({
157
+ type: 'override' as const,
158
+ label: options.label,
159
+ description: options.description,
160
+ defaultValue: { active: false } as { active: boolean } & Partial<
161
+ Record<keyof TFields, unknown>
162
+ >,
163
+ fields,
164
+ }) as OverrideFieldDef<TFields>
165
+ },
166
+ } as const
@@ -0,0 +1,215 @@
1
+ import type { z } from 'zod'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Core field definition types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /** Discriminator for all field types */
8
+ export type FieldType =
9
+ | 'text'
10
+ | 'number'
11
+ | 'boolean'
12
+ | 'select'
13
+ | 'icon'
14
+ | 'image'
15
+ | 'array'
16
+ | 'object'
17
+ | 'conditional'
18
+ | 'color'
19
+ | 'override'
20
+
21
+ /** Path to a field in a nested schema (e.g. ['items', 0, 'title']) */
22
+ export type FieldPath = ReadonlyArray<string | number>
23
+
24
+ /**
25
+ * Base field definition. The phantom type TValue carries the runtime value type
26
+ * through the type system without ever being assigned at runtime.
27
+ */
28
+ export interface FieldDefinition<TType extends FieldType = FieldType, TValue = unknown> {
29
+ readonly type: TType
30
+ readonly label: string
31
+ readonly description?: string
32
+ readonly defaultValue?: TValue
33
+ readonly required?: boolean
34
+ readonly _phantom?: TValue // phantom type – never assigned, only for inference
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Concrete field option types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface TextFieldOptions {
42
+ label: string
43
+ description?: string
44
+ defaultValue?: string
45
+ required?: boolean
46
+ multiline?: boolean
47
+ placeholder?: string
48
+ maxLength?: number
49
+ pattern?: RegExp
50
+ /** Enable inline formatting (bold, italic, strike, links). Output is HTML. */
51
+ formatting?: boolean
52
+ }
53
+
54
+ export interface TextFieldDef extends FieldDefinition<'text', string> {
55
+ readonly multiline: boolean
56
+ readonly placeholder?: string
57
+ readonly maxLength?: number
58
+ readonly pattern?: RegExp
59
+ readonly formatting: boolean
60
+ }
61
+
62
+ export interface NumberFieldOptions {
63
+ label: string
64
+ description?: string
65
+ defaultValue?: number
66
+ required?: boolean
67
+ min?: number
68
+ max?: number
69
+ step?: number
70
+ slider?: boolean
71
+ }
72
+
73
+ export interface NumberFieldDef extends FieldDefinition<'number', number> {
74
+ readonly min?: number
75
+ readonly max?: number
76
+ readonly step?: number
77
+ readonly slider: boolean
78
+ }
79
+
80
+ export interface BooleanFieldOptions {
81
+ label: string
82
+ description?: string
83
+ defaultValue?: boolean
84
+ }
85
+
86
+ export interface BooleanFieldDef extends FieldDefinition<'boolean', boolean> {}
87
+
88
+ export interface SelectOption {
89
+ readonly label: string
90
+ readonly value: string
91
+ readonly icon?: string
92
+ }
93
+
94
+ export interface SelectFieldOptions {
95
+ label: string
96
+ description?: string
97
+ defaultValue?: string
98
+ required?: boolean
99
+ options: readonly SelectOption[]
100
+ multi?: boolean
101
+ }
102
+
103
+ export interface SelectFieldDef extends FieldDefinition<'select', string> {
104
+ readonly options: readonly SelectOption[]
105
+ readonly multi: boolean
106
+ }
107
+
108
+ export interface IconFieldOptions {
109
+ label: string
110
+ description?: string
111
+ defaultValue?: string
112
+ required?: boolean
113
+ }
114
+
115
+ export interface IconFieldDef extends FieldDefinition<'icon', string> {}
116
+
117
+ export interface ImageFieldOptions {
118
+ label: string
119
+ description?: string
120
+ required?: boolean
121
+ directory: string
122
+ accept?: readonly string[]
123
+ maxSize?: string
124
+ filenameStrategy?: 'original' | 'slugified' | 'custom'
125
+ }
126
+
127
+ export interface ImageValue {
128
+ readonly path: string
129
+ readonly alt?: string
130
+ }
131
+
132
+ export interface ImageFieldDef extends FieldDefinition<'image', ImageValue> {
133
+ readonly directory: string
134
+ readonly accept: readonly string[]
135
+ readonly maxSize?: string
136
+ readonly filenameStrategy: 'original' | 'slugified' | 'custom'
137
+ }
138
+
139
+ export interface ArrayFieldOptions {
140
+ label: string
141
+ description?: string
142
+ required?: boolean
143
+ minItems?: number
144
+ maxItems?: number
145
+ itemLabel?: (item: unknown, index: number) => string
146
+ }
147
+
148
+ export interface ArrayFieldDef<TItem extends FieldDefinition = FieldDefinition>
149
+ extends FieldDefinition<'array', InferFieldValue<TItem>[]> {
150
+ readonly itemField: TItem
151
+ readonly minItems?: number
152
+ readonly maxItems?: number
153
+ readonly itemLabel?: (item: unknown, index: number) => string
154
+ }
155
+
156
+ export interface ObjectFieldOptions {
157
+ label: string
158
+ description?: string
159
+ required?: boolean
160
+ collapsible?: boolean
161
+ }
162
+
163
+ export type FieldRecord = Record<string, FieldDefinition>
164
+
165
+ export interface ObjectFieldDef<TFields extends FieldRecord = FieldRecord>
166
+ extends FieldDefinition<'object', InferSchemaValues<TFields>> {
167
+ readonly fields: TFields
168
+ readonly collapsible: boolean
169
+ }
170
+
171
+ export interface ColorFieldOptions {
172
+ label: string
173
+ description?: string
174
+ defaultValue?: string
175
+ presets?: readonly string[]
176
+ }
177
+
178
+ export interface ColorFieldDef extends FieldDefinition<'color', string> {
179
+ readonly presets: readonly string[]
180
+ }
181
+
182
+ export interface OverrideFieldOptions {
183
+ label: string
184
+ description?: string
185
+ }
186
+
187
+ export interface OverrideFieldDef<TFields extends FieldRecord = FieldRecord>
188
+ extends FieldDefinition<'override', { active: boolean } & Partial<InferSchemaValues<TFields>>> {
189
+ readonly fields: TFields
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Type inference utilities
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /** Extract the runtime value type from a FieldDefinition */
197
+ export type InferFieldValue<F> = F extends FieldDefinition<infer _T, infer V> ? V : never
198
+
199
+ /** Infer all values from a schema record */
200
+ export type InferSchemaValues<S extends FieldRecord> = {
201
+ [K in keyof S]: InferFieldValue<S[K]>
202
+ }
203
+
204
+ /** Union of all concrete field definitions */
205
+ export type AnyFieldDef =
206
+ | TextFieldDef
207
+ | NumberFieldDef
208
+ | BooleanFieldDef
209
+ | SelectFieldDef
210
+ | IconFieldDef
211
+ | ImageFieldDef
212
+ | ArrayFieldDef
213
+ | ObjectFieldDef
214
+ | ColorFieldDef
215
+ | OverrideFieldDef