@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.
- package/LICENSE +37 -0
- package/dist/chunk-IL2PWN4R.js +142 -0
- package/dist/define-config-bJ65Zaui.d.ts +209 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +314 -0
- package/dist/testing.d.ts +28 -0
- package/dist/testing.js +69 -0
- package/package.json +32 -0
- package/src/commands/command.test.ts +85 -0
- package/src/commands/command.ts +65 -0
- package/src/errors/errors.ts +112 -0
- package/src/events/content-event-bus.test.ts +59 -0
- package/src/events/content-event-bus.ts +55 -0
- package/src/fields/factories.test.ts +168 -0
- package/src/fields/factories.ts +166 -0
- package/src/fields/field-definition.ts +215 -0
- package/src/index.ts +85 -0
- package/src/ports/asset-store.ts +36 -0
- package/src/ports/auth-provider.ts +32 -0
- package/src/ports/content-repository.ts +59 -0
- package/src/schema/define-config.ts +96 -0
- package/src/serialization/json-serializer.ts +87 -0
- package/src/serialization/serializer.ts +65 -0
- package/src/testing/index.ts +72 -0
- package/src/validation/schema-to-zod.test.ts +133 -0
- package/src/validation/schema-to-zod.ts +126 -0
|
@@ -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
|